mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-12 02:17:40 +00:00
Merge branch 'dev' into refactor/standardize-dropdowns
This commit is contained in:
@@ -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))
|
||||
: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user