Merge branch 'dev' into refactor/standardize-dropdowns

This commit is contained in:
Fred KISSIE
2026-06-03 19:34:22 +02:00
30 changed files with 808 additions and 271 deletions

View File

@@ -103,6 +103,7 @@ export default function CreatePrivateResourceDialog({
data.alias.trim()
? data.alias
: undefined,
destinationPort: data.destinationPort ?? undefined,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
@@ -112,13 +113,14 @@ export default function CreatePrivateResourceDialog({
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
...(data.mode === "ssh" && {
disableIcmp: data.disableIcmp ?? false
}),
roleIds: data.roles
? data.roles.map((r) => parseInt(r.id))
: [],

View File

@@ -16,10 +16,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type {
CreateRoleBody,
CreateRoleResponse
} from "@server/routers/role";
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
@@ -50,7 +47,7 @@ export default function CreateRoleForm({
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.sshPam)) {
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
@@ -69,10 +66,9 @@ export default function CreateRoleForm({
}
}
const res = await api
.put<AxiosResponse<CreateRoleResponse>>(
`/org/${org?.org.orgId}/role`,
payload
)
.put<
AxiosResponse<CreateRoleResponse>
>(`/org/${org?.org.orgId}/role`, payload)
.catch((e) => {
toast({
variant: "destructive",

View File

@@ -104,6 +104,7 @@ export default function EditPrivateResourceDialog({
data.alias.trim()
? data.alias
: null,
destinationPort: data.destinationPort ?? null,
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
@@ -112,13 +113,14 @@ export default function EditPrivateResourceDialog({
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" ||
data.mode === "ssh" ||
data.mode === "cidr") && {
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
...(data.mode === "ssh" && {
disableIcmp: data.disableIcmp ?? false
}),
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))

View File

@@ -16,10 +16,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import type { Role } from "@server/db";
import type {
UpdateRoleBody,
UpdateRoleResponse
} from "@server/routers/role";
import type { UpdateRoleBody, UpdateRoleResponse } from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
@@ -53,7 +50,7 @@ export default function EditRoleForm({
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.sshPam)) {
if (isPaidUser(tierMatrix.advancedPrivateResources)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
@@ -72,10 +69,9 @@ export default function EditRoleForm({
}
}
const res = await api
.post<AxiosResponse<UpdateRoleResponse>>(
`/role/${role.roleId}`,
payload
)
.post<
AxiosResponse<UpdateRoleResponse>
>(`/role/${role.roleId}`, payload)
.catch((e) => {
toast({
variant: "destructive",

View File

@@ -41,6 +41,7 @@ import {
TooltipTrigger
} from "@/components/ui/tooltip";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@/components/ui/badge";
// Update Resource type to include site information
type Resource = {
@@ -49,7 +50,7 @@ type Resource = {
domain: string;
enabled: boolean;
protected: boolean;
// mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh"
mode: string; // "http", "tcp", "udp", "rdp", "vnc", "ssh"
// Auth method fields
sso?: boolean;
password?: boolean;
@@ -62,6 +63,7 @@ type Resource = {
type SiteResource = {
siteResourceId: number;
name: string;
niceId: string;
destination: string;
mode: string;
ssl: boolean;
@@ -754,7 +756,13 @@ export default function MemberResourcesPortal({
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<div className="flex-shrink-0 flex items-center gap-2">
<Badge
variant="secondary"
className="text-xs"
>
{resource.mode.toUpperCase()}
</Badge>
<ResourceInfo
resource={resource}
/>
@@ -860,7 +868,13 @@ export default function MemberResourcesPortal({
</TooltipProvider>
</div>
<div className="flex-shrink-0">
<div className="flex-shrink-0 flex items-center gap-2">
<Badge
variant="secondary"
className="text-xs"
>
{siteResource.mode.toUpperCase()}
</Badge>
<InfoPopup>
<div className="space-y-2 text-sm">
<div className="text-xs font-medium mb-1.5">
@@ -876,24 +890,24 @@ export default function MemberResourcesPortal({
:
</span>
<span className="ml-2 text-muted-foreground capitalize">
{
siteResource.mode
}
</span>
</div>
<div>
<span className="font-medium">
{t(
"memberPortalDestination"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
siteResource.destination
}
{siteResource.mode.toUpperCase()}
</span>
</div>
{siteResource.destination && (
<div>
<span className="font-medium">
{t(
"memberPortalDestination"
)}
:
</span>
<span className="ml-2 text-muted-foreground">
{
siteResource.destination
}
</span>
</div>
)}
{siteResource.alias && (
<div>
<span className="font-medium">
@@ -942,45 +956,35 @@ export default function MemberResourcesPortal({
isLink={true}
/>
) : siteResource.alias ? (
<>
{/* Alias as primary */}
<div className="flex items-center gap-2 mb-1">
<div className="text-base font-semibold text-foreground text-left truncate flex-1">
{
siteResource.alias
}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.alias!
);
toast({
title: t(
"memberPortalCopiedToClipboard"
/* Alias as primary */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{siteResource.alias}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.alias!
);
toast({
title: t(
"memberPortalCopiedToClipboard"
),
description:
t(
"memberPortalCopiedAliasDescription"
),
description:
t(
"memberPortalCopiedAliasDescription"
),
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
{/* Destination as secondary */}
<div className="text-xs text-muted-foreground truncate">
{
siteResource.destination
}
</div>
</>
) : (
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
) : siteResource.destination ? (
/* Destination as primary when no alias */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
@@ -1011,6 +1015,37 @@ export default function MemberResourcesPortal({
<Copy className="h-4 w-4" />
</Button>
</div>
) : (
/* niceId fallback when no alias and no destination */
<div className="flex items-center gap-2">
<div className="text-sm text-muted-foreground font-medium text-left truncate flex-1">
{
siteResource.niceId
}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
onClick={() => {
navigator.clipboard.writeText(
siteResource.niceId
);
toast({
title: t(
"memberPortalCopiedToClipboard"
),
description:
t(
"memberPortalCopiedDestinationDescription"
),
duration: 2000
});
}}
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>

View File

@@ -224,8 +224,10 @@ export function PrivateResourceForm({
const { env } = useEnvContext();
const { isPaidUser } = usePaidStatus();
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const sshSectionDisabled = !isPaidUser(tierMatrix.sshPam);
const httpSectionDisabled = !isPaidUser(tierMatrix.httpPrivateResources);
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const nameRequiredKey =
variant === "create"
@@ -365,6 +367,19 @@ export function PrivateResourceForm({
path: ["destination"]
});
}
if (data.mode === "ssh" && !isNativeSsh) {
if (
data.destinationPort == null ||
!Number.isFinite(data.destinationPort) ||
data.destinationPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["destinationPort"]
});
}
}
if (data.mode !== "http") return;
if (!data.scheme) {
ctx.addIssue({
@@ -548,7 +563,7 @@ export function PrivateResourceForm({
mode: "host",
destination: "",
alias: null,
destinationPort: null,
destinationPort: 22,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
@@ -581,6 +596,7 @@ export function PrivateResourceForm({
const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpMode = mode === "http";
const isSshMode = mode === "ssh";
const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const pamMode = form.watch("pamMode") ?? "passthrough";
const isNative = sshServerMode === "native";
@@ -726,8 +742,17 @@ export function PrivateResourceForm({
]);
useEffect(() => {
onSubmitDisabledChange?.(isHttpMode && httpSectionDisabled);
}, [isHttpMode, httpSectionDisabled, onSubmitDisabledChange]);
onSubmitDisabledChange?.(
(isHttpMode && httpSectionDisabled) ||
(isSshMode && sshSectionDisabled)
);
}, [
isHttpMode,
httpSectionDisabled,
isSshMode,
sshSectionDisabled,
onSubmitDisabledChange
]);
return (
<Form {...form}>
@@ -735,6 +760,7 @@ export function PrivateResourceForm({
onSubmit={form.handleSubmit((values) => {
const siteIds = values.siteIds;
const trimmedDestination = values.destination?.trim();
const isSshMode = values.mode === "ssh";
onSubmit({
...values,
siteIds,
@@ -742,6 +768,12 @@ export function PrivateResourceForm({
trimmedDestination && trimmedDestination.length > 0
? trimmedDestination
: null,
tcpPortRangeString: isSshMode
? undefined
: values.tcpPortRangeString,
udpPortRangeString: isSshMode
? undefined
: values.udpPortRangeString,
clients: (values.clients ?? []).map((c) => ({
id: c.clientId.toString(),
text: c.name
@@ -826,8 +858,11 @@ export function PrivateResourceForm({
{t("sites")}
</FormLabel>
{mode === "ssh" &&
sshServerMode ===
"native" ? (
(sshServerMode ===
"native" ||
(pamMode === "push" &&
authDaemonMode ===
"site")) ? (
<Popover>
<PopoverTrigger
asChild
@@ -1106,8 +1141,10 @@ export function PrivateResourceForm({
""
}
disabled={
isHttpMode &&
httpSectionDisabled
(isHttpMode &&
httpSectionDisabled) ||
(isSshMode &&
sshSectionDisabled)
}
onChange={(e) =>
field.onChange(
@@ -1146,6 +1183,10 @@ export function PrivateResourceForm({
field.value ??
""
}
disabled={
isSshMode &&
sshSectionDisabled
}
/>
</FormControl>
<FormMessage />
@@ -1179,7 +1220,10 @@ export function PrivateResourceForm({
""
}
disabled={
httpSectionDisabled
(isHttpMode &&
httpSectionDisabled) ||
(isSshMode &&
sshSectionDisabled)
}
onChange={(e) => {
const raw =
@@ -1214,9 +1258,9 @@ export function PrivateResourceForm({
</div>
</div>
{isHttpMode && (
{(isHttpMode || isSshMode) && (
<PaidFeaturesAlert
tiers={tierMatrix.httpPrivateResources}
tiers={tierMatrix.advancedPrivateResources}
/>
)}
@@ -1750,7 +1794,9 @@ export function PrivateResourceForm({
{/* SSH Access tab (ssh mode only) */}
{!disableEnterpriseFeatures && mode === "ssh" && (
<div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
{/* Mode */}
<div className="space-y-3">
@@ -1862,6 +1908,36 @@ export function PrivateResourceForm({
"authDaemonPort",
null
);
} else if (
v === "push"
) {
// push + site (default) = single site
const curAuthMode =
form.getValues(
"authDaemonMode"
);
if (
curAuthMode !==
"remote" &&
selectedSites.length >
1
) {
const first =
selectedSites.slice(
0,
1
);
setSelectedSites(
first
);
form.setValue(
"siteIds",
first.map(
(s) =>
s.siteId
)
);
}
}
}}
cols={2}
@@ -1929,6 +2005,29 @@ export function PrivateResourceForm({
"authDaemonPort",
null
);
// site daemon = single site
if (
selectedSites.length >
1
) {
const first =
selectedSites.slice(
0,
1
);
setSelectedSites(
first
);
form.setValue(
"siteIds",
first.map(
(
s
) =>
s.siteId
)
);
}
}
}}
cols={2}

View File

@@ -164,7 +164,7 @@ export function RoleForm({
}
}, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
const sshDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
@@ -319,7 +319,9 @@ export function RoleForm({
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<FormField
control={form.control}
name="allowSsh"