make form grids more consistent

This commit is contained in:
miloschwartz
2026-06-08 15:59:54 -07:00
parent 1b7c1ffa70
commit 135a5d38af
10 changed files with 1201 additions and 1010 deletions
@@ -10,6 +10,8 @@ import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle, SettingsSectionTitle,
@@ -1324,42 +1326,44 @@ export default function BillingPage() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <SettingsFormGrid>
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4"> <SettingsFormCell span="half">
<div> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div className="text-sm text-muted-foreground mb-1"> <div>
{t("billingCurrentKeys") || <div className="text-sm text-muted-foreground mb-1">
"Current Keys"} {t("billingCurrentKeys") ||
</div> "Current Keys"}
<div className="flex items-baseline gap-2"> </div>
<span className="text-3xl font-semibold"> <div className="flex items-baseline gap-2">
{getLicenseKeyCount()} <span className="text-3xl font-semibold">
</span> {getLicenseKeyCount()}
<span className="text-lg"> </span>
{getLicenseKeyCount() === 1 <span className="text-lg">
? "key" {getLicenseKeyCount() === 1
: "keys"} ? "key"
</span> : "keys"}
</span>
</div>
</div> </div>
<Button
variant="outline"
onClick={handleModifySubscription}
disabled={isLoading}
loading={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div> </div>
<Button </SettingsFormCell>
variant="outline" </SettingsFormGrid>
onClick={handleModifySubscription}
disabled={isLoading}
loading={isLoading}
>
<CreditCard className="mr-2 h-4 w-4" />
{t("billingModifyCurrentPlan") ||
"Modify Current Plan"}
</Button>
<p className="text-sm text-muted-foreground mt-2">
{t(
"billingManageLicenseSubscriptionDescription"
) ||
"Manage your subscription for paid self-hosted license keys and download invoices."}
</p>
</div>
</div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
)} )}
@@ -9,6 +9,8 @@ import {
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -257,80 +259,87 @@ export default function Page() {
e.preventDefault(); // block default enter refresh e.preventDefault(); // block default enter refresh
} }
}} }}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-client-form" id="create-client-form"
> >
<FormField <SettingsFormGrid>
control={form.control} <SettingsFormCell span="half">
name="name" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="name"
<FormLabel> render={({ field }) => (
{t("name")} <FormItem>
</FormLabel> <FormLabel>
<FormControl> {t("name")}
<Input </FormLabel>
autoComplete="off" <FormControl>
{...field} <Input
/> autoComplete="off"
</FormControl> {...field}
<FormMessage /> />
<FormDescription> </FormControl>
{t( <FormMessage />
"clientNameDescription" <FormDescription>
)} {t(
</FormDescription> "clientNameDescription"
</FormItem>
)}
/>
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
{showAdvancedSettings && (
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2">
<FormLabel>
{t("clientAddress")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)} )}
{...field} </FormDescription>
/> </FormItem>
</FormControl> )}
<FormMessage /> />
<FormDescription> </SettingsFormCell>
{t( <SettingsFormCell className="flex items-center justify-end md:col-span-2">
"addressDescription" <Button
)} type="button"
</FormDescription> variant="ghost"
</FormItem> size="sm"
)} onClick={() =>
/> setShowAdvancedSettings(
)} !showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</SettingsFormCell>
{showAdvancedSettings && (
<SettingsFormCell span="full">
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"clientAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
placeholder={t(
"subnetPlaceholder"
)}
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"addressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</form> </form>
</Form> </Form>
</SettingsSectionBody> </SettingsSectionBody>
@@ -20,6 +20,8 @@ import {
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm, SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
@@ -616,207 +618,225 @@ export default function GeneralForm() {
<Form {...form}> <Form {...form}>
<form <form
action={formAction} action={formAction}
className="space-y-4"
id="general-settings-form" id="general-settings-form"
> >
<FormField <SettingsFormGrid>
control={form.control} <SettingsFormCell span="full">
name="enabled"
render={() => (
<FormItem>
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={t(
"resourceEnable"
)}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
<FormDescription>
{t(
"disabledResourceDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{!["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<>
<FormField <FormField
control={form.control} control={form.control}
name="proxyPort" name="enabled"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl> <FormControl>
<Input <SwitchInput
type="number" id="enable-resource"
value={ defaultChecked={
field.value !== resource.enabled
undefined
? String(
field.value
)
: ""
} }
onChange={(e) => label={t(
field.onChange( "resourceEnable"
e.target )}
.value onCheckedChange={(
? parseInt( val
e ) =>
.target form.setValue(
.value "enabled",
) val
: undefined
) )
} }
/> />
</FormControl> </FormControl>
<FormMessage />
<FormDescription> <FormDescription>
{t( {t(
"resourcePortNumberDescription" "disabledResourceDescription"
)} )}
</FormDescription> </FormDescription>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</> </SettingsFormCell>
)}
{["http", "ssh", "rdp", "vnc"].includes( <SettingsFormCell span="half">
resource.mode <FormField
) && ( control={form.control}
<div className="space-y-4"> name="name"
<div id="resource-domain-picker"> render={({ field }) => (
<DomainPicker <FormItem>
allowWildcard={true} <FormLabel>
key={resource.resourceId} {t("name")}
orgId={orgId as string} </FormLabel>
cols={2} <FormControl>
defaultSubdomain={ <Input
form.watch( {...field}
"subdomain" />
) ?? undefined </FormControl>
} <FormMessage />
defaultDomainId={ </FormItem>
form.watch( )}
"domainId" />
) ?? undefined </SettingsFormCell>
}
defaultFullDomain={ <SettingsFormCell span="half">
resourceFullDomainName || <FormField
undefined control={form.control}
} name="niceId"
onDomainChange={(res) => { render={({ field }) => (
if (res === null) { <FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t(
"enterIdentifier"
)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{!["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value !==
undefined
? String(
field.value
)
: ""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortNumberDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
)}
{["http", "ssh", "rdp", "vnc"].includes(
resource.mode
) && (
<SettingsFormCell span="full">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={
resource.resourceId
}
orgId={orgId as string}
cols={2}
defaultSubdomain={
form.watch(
"subdomain"
) ?? undefined
}
defaultDomainId={
form.watch(
"domainId"
) ?? undefined
}
defaultFullDomain={
resourceFullDomainName ||
undefined
}
onDomainChange={(
res
) => {
if (res === null) {
form.setValue(
"domainId",
undefined
);
form.setValue(
"subdomain",
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://`
);
return;
}
form.setValue( form.setValue(
"domainId", "domainId",
undefined res.domainId
); );
form.setValue( form.setValue(
"subdomain", "subdomain",
undefined res.subdomain ??
undefined
); );
setResourceFullDomain( setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://` `${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
); );
return; }}
/>
</div>
</SettingsFormCell>
)}
{showResourcePolicy && (
<SettingsFormCell span="half">
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
} }
form.setValue( orgId={org.org.orgId}
"domainId", value={
res.domainId selectedSharedPolicyId
); }
form.setValue( onChange={
"subdomain", setSelectedSharedPolicyId
res.subdomain ?? }
undefined />
); </div>
setResourceFullDomain( </SettingsFormCell>
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}` )}
); </SettingsFormGrid>
}}
/>
</div>
</div>
)}
{showResourcePolicy && (
<div className="space-y-2">
<FormLabel>
{t("sharedPolicy")}
</FormLabel>
<SharedPolicySelect
key={
resource.resourcePolicyId ??
"none"
}
orgId={org.org.orgId}
value={selectedSharedPolicyId}
onChange={
setSelectedSharedPolicyId
}
/>
</div>
)}
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
@@ -5,6 +5,8 @@ import {
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsFormCell,
SettingsFormGrid,
SettingsSectionForm, SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle, SettingsSectionTitle,
@@ -410,174 +412,199 @@ function SshServerForm({
<Form {...form}> <Form {...form}>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
<div className="space-y-2"> <SettingsFormGrid>
<p className="font-semibold text-sm">{t("sshServerMode")}</p> <SettingsFormCell span="full">
<Badge variant="secondary"> <div className="space-y-2">
{sshServerMode == "standard" <p className="font-semibold text-sm">
? t("sshServerModeStandard") {t("sshServerMode")}
: t("sshServerModePangolin")} </p>
</Badge> <Badge variant="secondary">
</div> {sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
</SettingsFormCell>
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p> <div className="space-y-2">
<StrategySelect<"passthrough" | "push"> <p className="font-semibold text-sm">
value={pamMode} {t("sshAuthenticationMethod")}
options={authMethodOptions} </p>
onChange={(value) => <StrategySelect<"passthrough" | "push">
form.setValue("pamMode", value, { value={pamMode}
shouldValidate: true options={authMethodOptions}
}) onChange={(value) =>
} form.setValue("pamMode", value, {
cols={2} shouldValidate: true
/> })
</div> }
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && ( {showDaemonLocation && (
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p> <div className="space-y-2">
<StrategySelect<"site" | "remote"> <p className="font-semibold text-sm">
value={standardDaemonLocation} {t("sshAuthDaemonLocation")}
options={daemonLocationOptions} </p>
onChange={(value) => <StrategySelect<"site" | "remote">
form.setValue( value={standardDaemonLocation}
"standardDaemonLocation", options={daemonLocationOptions}
value, onChange={(value) =>
{ shouldValidate: true } form.setValue(
) "standardDaemonLocation",
} value,
cols={2} {
/> shouldValidate: true
<p className="text-sm text-muted-foreground"> }
{t("sshDaemonDisclaimer")}{" "} )
<a }
href="https://docs.pangolin.net/manage/resources/public/ssh" cols={2}
target="_blank" />
rel="noopener noreferrer" <p className="text-sm text-muted-foreground">
className="text-primary hover:underline inline-flex items-center gap-1" {t("sshDaemonDisclaimer")}{" "}
> <a
{t("learnMore")} href="https://docs.pangolin.net/manage/resources/public/ssh"
<ExternalLink className="size-3.5 shrink-0" /> target="_blank"
</a> rel="noopener noreferrer"
</p> className="text-primary hover:underline inline-flex items-center gap-1"
</div>
)}
{showDaemonPort && (
<div className="w-full md:w-1/2">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="space-y-3">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
{isNative ? (
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
> >
<PopoverTrigger asChild> {t("learnMore")}
<FormControl> <ExternalLink className="size-3.5 shrink-0" />
<Button </a>
variant="outline" </p>
role="combobox" </div>
className="w-full max-w-xs justify-between font-normal" </SettingsFormCell>
>
<span className="truncate">
{selectedNativeSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
) : useMultiSiteTargetForm ? (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
) : (
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
)} )}
</div>
{showDaemonPort && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative ? (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="selectedNativeSite"
render={() => (
<FormItem>
<Popover
open={nativeSiteOpen}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{selectedNativeSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={orgId}
selectedSite={
selectedNativeSite
}
onSelectSite={(
site
) => {
form.setValue(
"selectedNativeSite",
site,
{
shouldValidate:
true
}
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
) : useMultiSiteTargetForm ? (
<SettingsFormCell span="full">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={true}
sitesField="selectedSites"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</SettingsFormCell>
) : (
<SettingsFormCell span="full">
<BrowserGatewayTargetForm
control={form.control}
orgId={orgId}
multiSite={false}
siteField="selectedSite"
destinationField="destination"
destinationPortField="destinationPort"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<form action={formAction} className="flex justify-end mt-4"> <form action={formAction} className="flex justify-end mt-4">
@@ -4,6 +4,8 @@ import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker"; import DomainPicker from "@app/components/DomainPicker";
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -806,172 +808,198 @@ export default function Page() {
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
{/* Name */} <SettingsFormGrid>
<Form {...baseForm}> <SettingsFormCell span="half">
<form <Form {...baseForm}>
onKeyDown={(e) => { <form
if (e.key === "Enter") { onKeyDown={(e) => {
e.preventDefault(); if (
} e.key ===
}} "Enter"
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start" ) {
id="base-resource-form" e.preventDefault();
> }
<FormField }}
control={baseForm.control} id="base-resource-form"
name="name" >
render={({ field }) => ( <FormField
<FormItem> control={
<FormLabel> baseForm.control
{t("name")} }
</FormLabel> name="name"
<FormControl> render={({
<Input field
{...field} }) => (
/> <FormItem>
</FormControl> <FormLabel>
<FormMessage /> {t(
<FormDescription> "name"
{t( )}
"resourceNameDescription" </FormLabel>
)} <FormControl>
</FormDescription> <Input
</FormItem> {...field}
)} />
/> </FormControl>
</form> <FormMessage />
</Form> <FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsFormCell>
{/* Inline Type Selector */} <SettingsFormCell span="full">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{t("type")} {t("type")}
</p> </p>
<OptionSelect<NewResourceType> <OptionSelect<NewResourceType>
options={typeOptions} options={typeOptions}
value={resourceType} value={resourceType}
onChange={setResourceType} onChange={
cols={6} setResourceType
/>
<p className="text-sm text-muted-foreground">
{t("resourceTypeDescription")}
</p>
</div>
{/* Domain/Subdomain (HTTP-based types) */}
{isHttpResource && (
<Form {...httpForm}>
<FormField
control={httpForm.control}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (!res)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</Form>
)}
{/* Proxy Port (TCP/UDP types) */}
{!isHttpResource && (
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
} }
}} cols={6}
className="grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="tcp-udp-settings-form"
>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortDescription"
)}
</FormDescription>
</FormItem>
)}
/> />
</form> <p className="text-sm text-muted-foreground">
</Form> {t(
)} "resourceTypeDescription"
)}
</p>
</div>
</SettingsFormCell>
{isHttpResource && (
<SettingsFormCell span="full">
<Form {...httpForm}>
<FormField
control={
httpForm.control
}
name="domainId"
render={() => (
<FormItem>
<DomainPicker
allowWildcard={
true
}
orgId={
orgId as string
}
warnOnProvidedDomain={
remoteExitNodes.length >=
1
}
onDomainChange={(
res
) => {
if (
!res
)
return;
httpForm.setValue(
"subdomain",
res.subdomain,
{
shouldValidate:
true
}
);
httpForm.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</Form>
</SettingsFormCell>
)}
{!isHttpResource && (
<SettingsFormCell span="half">
<Form {...tcpUdpForm}>
<form
onKeyDown={(e) => {
if (
e.key ===
"Enter"
) {
e.preventDefault();
}
}}
id="tcp-udp-settings-form"
>
<FormField
control={
tcpUdpForm.control
}
name="proxyPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: undefined
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourcePortDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -1005,202 +1033,237 @@ export default function Page() {
> >
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm variant="half"> <SettingsSectionForm variant="half">
{/* Mode */} <SettingsFormGrid>
<div className="space-y-2"> <SettingsFormCell span="full">
<p className="font-semibold text-sm">{t("sshServerMode")}</p> <div className="space-y-2">
<StrategySelect< <p className="font-semibold text-sm">
"standard" | "native" {t("sshServerMode")}
> </p>
value={sshServerMode} <StrategySelect<
options={sshModeOptions} "standard" | "native"
onChange={setSshServerMode}
cols={2}
/>
</div>
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthenticationMethod")}</p>
<StrategySelect<
"passthrough" | "push"
>
value={pamMode}
options={
authMethodOptions
}
onChange={setPamMode}
cols={2}
/>
</div>
{/* Daemon Location (standard + push) */}
{showDaemonLocation && (
<div className="space-y-2">
<p className="font-semibold text-sm">{t("sshAuthDaemonLocation")}</p>
<StrategySelect<
"site" | "remote"
>
value={
standardDaemonLocation
}
options={
daemonLocationOptions
}
onChange={
setStandardDaemonLocation
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t(
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
> >
{t("learnMore")} value={sshServerMode}
<ExternalLink className="size-3.5 shrink-0" /> options={
</a> sshModeOptions
</p>
</div>
)}
{/* Daemon Port (standard + push + remote) */}
{showDaemonPort && (
<Form {...sshDaemonPortForm}>
<div className="w-full md:w-1/2">
<FormField
control={
sshDaemonPortForm.control
} }
name="authDaemonPort" onChange={
render={({ setSshServerMode
field }
}) => ( cols={2}
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> />
</div> </div>
</Form> </SettingsFormCell>
)}
{/* Server Destination */} <SettingsFormCell span="full">
<div className="space-y-3"> <div className="space-y-2">
<SettingsSubsectionHeader> <p className="font-semibold text-sm">
<SettingsSubsectionTitle> {t(
{t( "sshAuthenticationMethod"
"sshServerDestination" )}
)} </p>
</SettingsSubsectionTitle> <StrategySelect<
<SettingsSubsectionDescription> "passthrough" | "push"
{t( >
"sshServerDestinationDescription" value={pamMode}
)} options={
</SettingsSubsectionDescription> authMethodOptions
</SettingsSubsectionHeader> }
{isNative ? ( onChange={setPamMode}
<Popover cols={2}
open={nativeSiteOpen} />
onOpenChange={ </div>
setNativeSiteOpen </SettingsFormCell>
}
> {showDaemonLocation && (
<PopoverTrigger asChild> <SettingsFormCell span="full">
<Button <div className="space-y-2">
variant="outline" <p className="font-semibold text-sm">
role="combobox" {t(
className="w-full md:w-1/2 justify-between font-normal" "sshAuthDaemonLocation"
)}
</p>
<StrategySelect<
"site" | "remote"
> >
<span className="truncate"> value={
{nativeSelectedSite?.name ?? standardDaemonLocation
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
} }
selectedSite={ options={
nativeSelectedSite daemonLocationOptions
} }
onSelectSite={( onChange={
site setStandardDaemonLocation
) => { }
setNativeSelectedSite( cols={2}
site
);
setNativeSiteOpen(
false
);
}}
/> />
</PopoverContent> <p className="text-sm text-muted-foreground">
</Popover> {t(
"sshDaemonDisclaimer"
)}{" "}
<a
href="https://docs.pangolin.net/manage/resources/public/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t(
"learnMore"
)}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<Form
{...sshDaemonPortForm}
>
<FormField
control={
sshDaemonPortForm.control
}
name="authDaemonPort"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"sshDaemonPort"
)}
</FormLabel>
<FormControl>
<Input
type="number"
min={
1
}
max={
65535
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Form>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(
"sshServerDestination"
)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(
"sshServerDestinationDescription"
)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative ? (
<SettingsFormCell span="half">
<Popover
open={
nativeSiteOpen
}
onOpenChange={
setNativeSiteOpen
}
>
<PopoverTrigger
asChild
>
<Button
variant="outline"
role="combobox"
className="w-full justify-between font-normal"
>
<span className="truncate">
{nativeSelectedSite?.name ??
t(
"siteSelect"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<SitesSelector
orgId={
orgId as string
}
selectedSite={
nativeSelectedSite
}
onSelectSite={(
site
) => {
setNativeSelectedSite(
site
);
setNativeSiteOpen(
false
);
}}
/>
</PopoverContent>
</Popover>
</SettingsFormCell>
) : standardDaemonLocation !== ) : standardDaemonLocation !==
"site" || "site" ||
pamMode === pamMode ===
"passthrough" ? ( "passthrough" ? (
<Form {...bgTargetForm}> <SettingsFormCell span="full">
<BrowserGatewayTargetForm <Form {...bgTargetForm}>
control={ <BrowserGatewayTargetForm
bgTargetForm.control control={
} bgTargetForm.control
orgId={ }
orgId as string orgId={
} orgId as string
multiSite={true} }
sitesField="selectedSites" multiSite={true}
destinationField="destination" sitesField="selectedSites"
destinationPortField="destinationPort" destinationField="destination"
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" destinationPortField="destinationPort"
defaultPort={22} learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
/> defaultPort={22}
</Form> />
</Form>
</SettingsFormCell>
) : ( ) : (
<Form {...bgTargetForm}> <SettingsFormCell span="full">
<BrowserGatewayTargetForm <Form {...bgTargetForm}>
control={ <BrowserGatewayTargetForm
bgTargetForm.control control={
} bgTargetForm.control
orgId={ }
orgId as string orgId={
} orgId as string
multiSite={false} }
siteField="selectedSite" multiSite={
destinationField="destination" false
destinationPortField="destinationPort" }
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh" siteField="selectedSite"
defaultPort={22} destinationField="destination"
/> destinationPortField="destinationPort"
</Form> learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
defaultPort={22}
/>
</Form>
</SettingsFormCell>
)} )}
</div> </SettingsFormGrid>
</SettingsSectionForm> </SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</fieldset> </fieldset>
+83 -72
View File
@@ -2,6 +2,8 @@
import { import {
SettingsContainer, SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -514,98 +516,107 @@ export default function Page() {
e.preventDefault(); // block default enter refresh e.preventDefault(); // block default enter refresh
} }
}} }}
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
id="create-site-form" id="create-site-form"
> >
<FormField <SettingsFormGrid>
control={form.control} <SettingsFormCell span="half">
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
{form.watch("method") === "newt" && (
<div className="flex items-center justify-end md:col-start-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="flex items-center gap-2"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t("advancedSettings")}
</Button>
</div>
)}
{form.watch("method") === "newt" &&
showAdvancedSettings && (
<FormField <FormField
control={form.control} control={form.control}
name="clientAddress" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-start-1 md:col-span-2"> <FormItem>
<FormLabel> <FormLabel>
{t( {t("name")}
"siteAddress"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
autoComplete="off" autoComplete="off"
value={ {...field}
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription> <FormDescription>
{t( {t(
"siteAddressDescription" "siteNameDescription"
)} )}
</FormDescription> </FormDescription>
</FormItem> </FormItem>
)} )}
/> />
)} {form.watch("method") ===
"newt" && (
<>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() =>
setShowAdvancedSettings(
!showAdvancedSettings
)
}
className="mt-2 flex items-center gap-2 -ml-3"
>
{showAdvancedSettings ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{t(
"advancedSettings"
)}
</Button>
{showAdvancedSettings && (
<FormField
control={
form.control
}
name="clientAddress"
render={({
field
}) => (
<FormItem className="mt-4">
<FormLabel>
{t(
"siteAddress"
)}
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e
.target
.value
);
field.onChange(
e
.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"siteAddressDescription"
)}
</FormDescription>
</FormItem>
)}
/>
)}
</>
)}
</SettingsFormCell>
</SettingsFormGrid>
</form> </form>
</Form> </Form>
</SettingsSectionBody> </SettingsSectionBody>
+43
View File
@@ -43,6 +43,49 @@ export function SettingsSectionForm({
); );
} }
export function SettingsFormGrid({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 md:grid-cols-4 gap-4 items-start",
className
)}
>
{children}
</div>
);
}
export function SettingsFormCell({
children,
span = "half",
className
}: {
children: React.ReactNode;
span?: "quarter" | "half" | "full";
className?: string;
}) {
return (
<div
className={cn(
"min-w-0",
span === "quarter" && "md:col-span-1",
span === "half" && "md:col-span-2",
span === "full" && "md:col-span-4",
className
)}
>
{children}
</div>
);
}
export function SettingsSectionTitle({ export function SettingsSectionTitle({
children children
}: { }: {
@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -111,65 +113,67 @@ export function PolicyAuthStackSectionCreate({
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div className="w-full md:w-1/2"> <SettingsFormGrid>
<PolicyAuthSsoSection <SettingsFormCell span="half">
sso={Boolean(sso)} <PolicyAuthSsoSection
onSsoChange={(active) => sso={Boolean(sso)}
parentForm.setValue("sso", active) onSsoChange={(active) =>
} parentForm.setValue("sso", active)
skipToIdpId={skipToIdpId} }
onSkipToIdpChange={(id) => skipToIdpId={skipToIdpId}
parentForm.setValue("skipToIdpId", id) onSkipToIdpChange={(id) =>
} parentForm.setValue("skipToIdpId", id)
allIdps={allIdps} }
rolesEditor={ allIdps={allIdps}
<FormField<PolicyFormValues, "roles"> rolesEditor={
control={parentForm.control} <FormField<PolicyFormValues, "roles">
name="roles" control={parentForm.control}
render={({ field }) => ( name="roles"
<TagInput render={({ field }) => (
{...field} <TagInput
activeTagIndex={activeRolesTagIndex} {...field}
setActiveTagIndex={ activeTagIndex={activeRolesTagIndex}
setActiveRolesTagIndex setActiveTagIndex={
} setActiveRolesTagIndex
placeholder={t("accessRoleSelect2")} }
tags={field.value ?? []} placeholder={t("accessRoleSelect2")}
setTags={(newRoles) => tags={field.value ?? []}
field.onChange(newRoles) setTags={(newRoles) =>
} field.onChange(newRoles)
autocompleteOptions={allRoles} }
allowDuplicates={false} autocompleteOptions={allRoles}
size="sm" allowDuplicates={false}
/> size="sm"
)} />
/> )}
} />
usersEditor={ }
<FormField<PolicyFormValues, "users"> usersEditor={
control={parentForm.control} <FormField<PolicyFormValues, "users">
name="users" control={parentForm.control}
render={({ field }) => ( name="users"
<TagInput render={({ field }) => (
{...field} <TagInput
activeTagIndex={activeUsersTagIndex} {...field}
setActiveTagIndex={ activeTagIndex={activeUsersTagIndex}
setActiveUsersTagIndex setActiveTagIndex={
} setActiveUsersTagIndex
placeholder={t("accessUserSelect")} }
tags={field.value ?? []} placeholder={t("accessUserSelect")}
setTags={(newUsers) => tags={field.value ?? []}
field.onChange(newUsers) setTags={(newUsers) =>
} field.onChange(newUsers)
autocompleteOptions={allUsers} }
allowDuplicates={false} autocompleteOptions={allUsers}
size="sm" allowDuplicates={false}
/> size="sm"
)} />
/> )}
} />
/> }
</div> />
</SettingsFormCell>
</SettingsFormGrid>
<SettingsSubsectionHeader> <SettingsSubsectionHeader>
<SettingsSubsectionTitle> <SettingsSubsectionTitle>
@@ -1,6 +1,8 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
@@ -475,101 +477,109 @@ export function PolicyAuthStackSectionEdit({
{isResourceOverlay && ( {isResourceOverlay && (
<SharedPolicyResourceNotice section="authentication" /> <SharedPolicyResourceNotice section="authentication" />
)} )}
<div className="w-full md:w-1/2"> <SettingsFormGrid>
<PolicyAuthSsoSection <SettingsFormCell span="half">
sso={Boolean(sso)} <PolicyAuthSsoSection
onSsoChange={(active) => sso={Boolean(sso)}
form.setValue("sso", active) onSsoChange={(active) =>
} form.setValue("sso", active)
skipToIdpId={skipToIdpId} }
onSkipToIdpChange={(id) => skipToIdpId={skipToIdpId}
form.setValue("skipToIdpId", id) onSkipToIdpChange={(id) =>
} form.setValue("skipToIdpId", id)
allIdps={allIdps} }
disabled={authReadonly} allIdps={allIdps}
idpDisabled={authReadonly} disabled={authReadonly}
rolesEditor={ idpDisabled={authReadonly}
isResourceOverlay ? ( rolesEditor={
<RolesSelector isResourceOverlay ? (
orgId={orgId} <RolesSelector
selectedRoles={overlayRoles} orgId={orgId}
onSelectRoles={(selected) => selectedRoles={overlayRoles}
setCombinedRoles( onSelectRoles={(selected) =>
selected.map( setCombinedRoles(
(role) => ({ selected.map(
...role, (role) => ({
isAdmin: ...role,
Boolean( isAdmin:
role.isAdmin Boolean(
) role.isAdmin
}) )
})
)
) )
) }
} disabled={isLoading}
disabled={isLoading} restrictAdminRole
restrictAdminRole lockedIds={
lockedIds={policyRoleLockedIds} policyRoleLockedIds
/> }
) : ( />
<FormField ) : (
control={form.control} <FormField
name="roles" control={form.control}
render={({ field }) => ( name="roles"
<RolesSelector render={({ field }) => (
orgId={orgId} <RolesSelector
selectedRoles={ orgId={orgId}
field.value selectedRoles={
} field.value
onSelectRoles={( }
selected onSelectRoles={(
) =>
form.setValue(
"roles",
selected selected
) ) =>
} form.setValue(
disabled={readonly} "roles",
restrictAdminRole selected
/> )
)} }
/> disabled={readonly}
) restrictAdminRole
} />
usersEditor={ )}
isResourceOverlay ? ( />
<UsersSelector )
orgId={orgId} }
selectedUsers={overlayUsers} usersEditor={
onSelectUsers={setCombinedUsers} isResourceOverlay ? (
disabled={isLoading} <UsersSelector
lockedIds={policyUserLockedIds} orgId={orgId}
/> selectedUsers={overlayUsers}
) : ( onSelectUsers={
<FormField setCombinedUsers
control={form.control} }
name="users" disabled={isLoading}
render={({ field }) => ( lockedIds={
<UsersSelector policyUserLockedIds
orgId={orgId} }
selectedUsers={ />
field.value ) : (
} <FormField
onSelectUsers={( control={form.control}
selected name="users"
) => render={({ field }) => (
form.setValue( <UsersSelector
"users", orgId={orgId}
selectedUsers={
field.value
}
onSelectUsers={(
selected selected
) ) =>
} form.setValue(
disabled={readonly} "users",
/> selected
)} )
/> }
) disabled={readonly}
} />
/> )}
</div> />
)
}
/>
</SettingsFormCell>
</SettingsFormGrid>
<SettingsSubsectionHeader> <SettingsSubsectionHeader>
<SettingsSubsectionTitle> <SettingsSubsectionTitle>
+1 -1
View File
@@ -183,7 +183,7 @@ export function SharedPolicySelect({
role="combobox" role="combobox"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
"w-full justify-between font-normal md:w-1/2", "w-full justify-between font-normal",
value !== null && value !== null &&
!resolvedLabel && !resolvedLabel &&
!fetchedPolicy?.name && !fetchedPolicy?.name &&