Merge branch 'dev' into resource-policies-restyle

This commit is contained in:
miloschwartz
2026-06-08 12:00:08 -07:00
45 changed files with 781 additions and 1468 deletions

View File

@@ -143,11 +143,6 @@ export function ProxyResourceTargetsForm({
const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] =
useState<LocalTarget | null>(null);
const [bgDestination, setBgDestination] = useState("");
const [bgDestinationPort, setBgDestinationPort] = useState("");
const [bgSiteId, setBgSiteId] = useState<number | null>(null);
const [bgTargetId, setBgTargetId] = useState<number | null>(null);
const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) {
return;
@@ -212,42 +207,6 @@ export function ProxyResourceTargetsForm({
})
);
// Browser-gateway targets (edit mode only)
const { data: bgTargetsResponse } = useQuery({
queryKey: ["browserGatewayTargets", resource?.resourceId, orgId],
queryFn: async () => {
const res = await api.get(
`/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets`
);
return res.data.data as {
targets: Array<{
browserGatewayTargetId: number;
resourceId: number;
siteId: number;
type: string;
destination: string;
destinationPort: number;
}>;
};
},
enabled: !!resource
});
useEffect(() => {
if (!bgTargetsResponse?.targets?.length) return;
const bgt = bgTargetsResponse.targets[0];
setBgDestination(bgt.destination);
setBgDestinationPort(String(bgt.destinationPort));
setBgSiteId(bgt.siteId);
setBgTargetId(bgt.browserGatewayTargetId);
}, [bgTargetsResponse]);
useEffect(() => {
if (sites.length > 0 && bgSiteId === null) {
setBgSiteId(sites[0].siteId);
}
}, [sites, bgSiteId]);
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
@@ -624,6 +583,8 @@ export function ProxyResourceTargetsForm({
const newTarget: LocalTarget = {
targetId: -Date.now(),
ip: "",
mode: ((resource?.mode as LocalTarget["mode"]) ??
(isHttp ? "http" : "tcp")) as LocalTarget["mode"],
method: isHttp ? "http" : null,
port: 0,
siteId: sites.length > 0 ? sites[0].siteId : 0,

View File

@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
type BgTarget = {
browserGatewayTargetId: number;
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
mode: string | null;
ip: string;
port: number;
};
type BgTargetsResponse = {
targets: BgTarget[];
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function RdpSettingsPage(props: {
@@ -61,13 +61,11 @@ export default function RdpSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "rdp"],
queryFn: async () => {
const res = await api.get(
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as BgTargetsResponse;
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
@@ -85,7 +83,7 @@ export default function RdpSettingsPage(props: {
resource={resource}
updateResource={updateResource}
disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
@@ -95,18 +93,18 @@ function RdpServerForm({
orgId,
resource,
disabled,
bgTargetsResponse
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
bgTargetsResponse: BgTargetsResponse;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = bgTargetsResponse.targets;
const targets = targetsResponse.targets.filter((t) => t.mode === "rdp");
const firstTarget = targets[0];
const formSchema = useMemo(
@@ -122,17 +120,15 @@ function RdpServerForm({
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.destination ?? "",
destinationPort: firstTarget
? String(firstTarget.destinationPort)
: "3389"
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "3389"
}
});
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId,
targetId: target.targetId,
siteId: target.siteId
}))
);
@@ -155,28 +151,20 @@ function RdpServerForm({
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "rdp",
destination,
destinationPort: Number(destinationPort),
siteId: t.siteId
}
)
api.post(`/target/${t.targetId}`, {
mode: "rdp",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
@@ -185,20 +173,18 @@ function RdpServerForm({
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "rdp",
destination,
destinationPort: Number(destinationPort)
}
)
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "rdp",
ip: destination,
port: Number(destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId: res.data.data.browserGatewayTargetId,
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);

View File

@@ -15,9 +15,7 @@ import {
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SitesSelector
} from "@app/components/site-selector";
import { SitesSelector } from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button";
@@ -54,22 +52,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
type BgTarget = {
browserGatewayTargetId: number;
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
mode: string | null;
ip: string;
port: number;
};
type BgTargetsResponse = {
targets: BgTarget[];
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function SshSettingsPage(props: {
@@ -83,13 +81,11 @@ export default function SshSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "ssh"],
queryFn: async () => {
const res = await api.get(
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as BgTargetsResponse;
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
@@ -107,7 +103,7 @@ export default function SshSettingsPage(props: {
resource={resource}
updateResource={updateResource}
disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
@@ -118,20 +114,20 @@ function SshServerForm({
resource,
updateResource,
disabled,
bgTargetsResponse
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
bgTargetsResponse: BgTargetsResponse;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const isNativeInitially = resource.authDaemonMode === "native";
const targets = bgTargetsResponse.targets;
const targets = targetsResponse.targets.filter((t) => t.mode === "ssh");
const firstTarget = targets[0];
const initialPamMode =
(resource.pamMode as "passthrough" | "push") || "passthrough";
@@ -192,11 +188,11 @@ function SshServerForm({
: null,
destination: isNativeInitially
? ""
: (firstTarget?.destination ?? ""),
: (firstTarget?.ip ?? ""),
destinationPort: isNativeInitially
? "22"
: firstTarget
? String(firstTarget.destinationPort)
? String(firstTarget.port)
: "22"
}
});
@@ -206,8 +202,8 @@ function SshServerForm({
isNativeInitially
? []
: targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId,
siteId: target.siteId
targetId: target.targetId,
siteId: target.siteId,
}))
);
@@ -215,14 +211,12 @@ function SshServerForm({
useState<ExistingTarget | null>(() =>
isNativeInitially && firstTarget
? {
browserGatewayTargetId:
firstTarget.browserGatewayTargetId,
siteId: firstTarget.siteId
targetId: firstTarget.targetId,
siteId: firstTarget.siteId,
}
: null
);
const [nativeSiteOpen, setNativeSiteOpen] = useState(false);
const [, formAction, isSubmitting] = useActionState(save, null);
const pamMode = form.watch("pamMode");
@@ -256,31 +250,37 @@ function SshServerForm({
});
if (isNative) {
if (values.selectedNativeSite) {
const nativeSite = values.selectedNativeSite;
if (nativeSite) {
if (nativeExistingTarget) {
await api.post(
`/org/${orgId}/browser-gateway-target/${nativeExistingTarget.browserGatewayTargetId}`,
`/target/${nativeExistingTarget.targetId}`,
{
type: "ssh",
destination: "localhost",
destinationPort: 22,
siteId: values.selectedNativeSite.siteId
}
);
} else {
const res = await api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: values.selectedNativeSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
siteId: nativeSite.siteId,
hcEnabled: false
}
);
setNativeExistingTarget({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: values.selectedNativeSite.siteId
...nativeExistingTarget,
siteId: nativeSite.siteId
});
} else {
const res = await api.put(
`/resource/${resource.resourceId}/target`,
{
siteId: nativeSite.siteId,
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
setNativeExistingTarget({
targetId: res.data.data.targetId,
siteId: nativeSite.siteId,
});
}
}
@@ -293,7 +293,6 @@ function SshServerForm({
: values.selectedSite
? [values.selectedSite]
: [];
const selectedSiteIds = new Set(
activeSites.map((s) => s.siteId)
);
@@ -305,11 +304,7 @@ function SshServerForm({
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
toDelete.map((t) => api.delete(`/target/${t.targetId}`))
);
const toUpdate = existingTargets.filter((t) =>
@@ -317,17 +312,13 @@ function SshServerForm({
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "ssh",
destination: values.destination,
destinationPort: Number(
values.destinationPort
),
siteId: t.siteId
}
)
api.post(`/target/${t.targetId}`, {
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
@@ -336,24 +327,19 @@ function SshServerForm({
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "ssh",
destination: values.destination,
destinationPort: Number(
values.destinationPort
)
}
)
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "ssh",
ip: values.destination,
port: Number(values.destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId:
res.data.data.browserGatewayTargetId,
siteId: toCreate[i].siteId
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId,
}));
setExistingTargets([...toUpdate, ...newTargets]);
}

View File

@@ -32,22 +32,22 @@ import { GetResourceResponse } from "@server/routers/resource";
import type { ResourceContextType } from "@app/contexts/resourceContext";
type ExistingTarget = {
browserGatewayTargetId: number;
targetId: number;
siteId: number;
};
type BgTarget = {
browserGatewayTargetId: number;
type TargetRow = {
targetId: number;
resourceId: number;
siteId: number;
siteName?: string;
type: string;
destination: string;
destinationPort: number;
mode: string | null;
ip: string;
port: number;
};
type BgTargetsResponse = {
targets: BgTarget[];
type ResourceTargetsResponse = {
targets: TargetRow[];
};
export default function VncSettingsPage(props: {
@@ -61,13 +61,11 @@ export default function VncSettingsPage(props: {
tierMatrix[TierFeature.AdvancedPublicResources]
);
const { data: bgTargetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["browserGatewayTargets", resource.resourceId, params.orgId],
const { data: targetsResponse, isLoading: isLoadingTargets } = useQuery({
queryKey: ["resourceTargets", resource.resourceId, params.orgId, "vnc"],
queryFn: async () => {
const res = await api.get(
`/org/${params.orgId}/resource/${resource.resourceId}/browser-gateway-targets`
);
return res.data.data as BgTargetsResponse;
const res = await api.get(`/resource/${resource.resourceId}/targets`);
return res.data.data as ResourceTargetsResponse;
}
});
@@ -85,7 +83,7 @@ export default function VncSettingsPage(props: {
resource={resource}
updateResource={updateResource}
disabled={disabled}
bgTargetsResponse={bgTargetsResponse ?? { targets: [] }}
targetsResponse={targetsResponse ?? { targets: [] }}
/>
</SettingsContainer>
);
@@ -95,18 +93,18 @@ function VncServerForm({
orgId,
resource,
disabled,
bgTargetsResponse
targetsResponse
}: {
orgId: string;
resource: GetResourceResponse;
updateResource: ResourceContextType["updateResource"];
disabled: boolean;
bgTargetsResponse: BgTargetsResponse;
targetsResponse: ResourceTargetsResponse;
}) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const router = useRouter();
const targets = bgTargetsResponse.targets;
const targets = targetsResponse.targets.filter((t) => t.mode === "vnc");
const firstTarget = targets[0];
const formSchema = useMemo(
@@ -122,17 +120,15 @@ function VncServerForm({
name: target.siteName ?? String(target.siteId),
type: "newt" as const
})),
destination: firstTarget?.destination ?? "",
destinationPort: firstTarget
? String(firstTarget.destinationPort)
: "5900"
destination: firstTarget?.ip ?? "",
destinationPort: firstTarget ? String(firstTarget.port) : "5900"
}
});
const [existingTargets, setExistingTargets] = useState<ExistingTarget[]>(
() =>
targets.map((target) => ({
browserGatewayTargetId: target.browserGatewayTargetId,
targetId: target.targetId,
siteId: target.siteId
}))
);
@@ -155,28 +151,20 @@ function VncServerForm({
const toDelete = existingTargets.filter(
(t) => !selectedSiteIds.has(t.siteId)
);
await Promise.all(
toDelete.map((t) =>
api.delete(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`
)
)
);
await Promise.all(toDelete.map((t) => api.delete(`/target/${t.targetId}`)));
const toUpdate = existingTargets.filter((t) =>
selectedSiteIds.has(t.siteId)
);
await Promise.all(
toUpdate.map((t) =>
api.post(
`/org/${orgId}/browser-gateway-target/${t.browserGatewayTargetId}`,
{
type: "vnc",
destination,
destinationPort: Number(destinationPort),
siteId: t.siteId
}
)
api.post(`/target/${t.targetId}`, {
mode: "vnc",
ip: destination,
port: Number(destinationPort),
siteId: t.siteId,
hcEnabled: false
})
)
);
@@ -185,20 +173,18 @@ function VncServerForm({
);
const created = await Promise.all(
toCreate.map((s) =>
api.put(
`/org/${orgId}/resource/${resource.resourceId}/browser-gateway-target`,
{
siteId: s.siteId,
type: "vnc",
destination,
destinationPort: Number(destinationPort)
}
)
api.put(`/resource/${resource.resourceId}/target`, {
siteId: s.siteId,
mode: "vnc",
ip: destination,
port: Number(destinationPort),
hcEnabled: false
})
)
);
const newTargets: ExistingTarget[] = created.map((res, i) => ({
browserGatewayTargetId: res.data.data.browserGatewayTargetId,
targetId: res.data.data.targetId,
siteId: toCreate[i].siteId
}));
setExistingTargets([...toUpdate, ...newTargets]);

View File

@@ -591,12 +591,13 @@ export default function Page() {
if (isNative) {
if (nativeSelectedSite) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: nativeSelectedSite.siteId,
type: "ssh",
destination: "localhost",
destinationPort: 22
mode: "ssh",
ip: "localhost",
port: 22,
hcEnabled: false
}
);
}
@@ -612,14 +613,13 @@ export default function Page() {
: [];
for (const site of sitesToCreate) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: "ssh",
destination: bgValues.destination,
destinationPort: Number(
bgValues.destinationPort
)
mode: "ssh",
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
hcEnabled: false
}
);
}
@@ -632,14 +632,13 @@ export default function Page() {
const bgValues = bgTargetForm.getValues();
for (const site of bgValues.selectedSites) {
await api.put(
`/org/${orgId}/resource/${id}/browser-gateway-target`,
`/resource/${id}/target`,
{
siteId: site.siteId,
type: resourceType,
destination: bgValues.destination,
destinationPort: Number(
bgValues.destinationPort
)
mode: resourceType,
ip: bgValues.destination,
port: Number(bgValues.destinationPort),
hcEnabled: false
}
);
}

View File

@@ -37,6 +37,10 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
declare module "react" {
namespace JSX {
@@ -63,22 +67,14 @@ type RdpCredentialsForm = {
enableClipboard: boolean;
};
function loadStoredCredentials(key: string): RdpCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as RdpCredentialsForm;
} catch {
// ignore
}
return {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
}
const DEFAULT_RDP_CREDENTIALS: RdpCredentialsForm = {
username: "",
password: "",
domain: "",
kdcProxyUrl: "",
pcb: "",
enableClipboard: true
};
const isIronError = (error: unknown): error is IronError => {
return (
@@ -113,9 +109,25 @@ export default function RdpClient({
const form = useForm<RdpCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_RDP_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<RdpCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_RDP_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [showLogin, setShowLogin] = useState(true);
const [moduleReady, setModuleReady] = useState(false);
const [connecting, setConnecting] = useState(false);
@@ -293,11 +305,11 @@ export default function RdpClient({
try {
const sessionInfo = await userInteraction.connect(builder.build());
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
values,
target.authToken
);
setConnecting(false);
setShowLogin(false);
userInteraction.setVisibility(true);

View File

@@ -32,6 +32,10 @@ import { useTranslations } from "next-intl";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type AuthTab = "password" | "privateKey";
@@ -48,15 +52,11 @@ type ConnectCredentials = {
certificate?: string;
};
function loadStoredCredentials(key: string): SshCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as SshCredentialsForm;
} catch {
// ignore
}
return { username: "", password: "", privateKey: "" };
}
const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = {
username: "",
password: "",
privateKey: ""
};
export default function SshClient({
target,
@@ -86,9 +86,25 @@ export default function SshClient({
});
const form = useForm<SshCredentialsForm>({
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_SSH_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<SshCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
@@ -252,14 +268,11 @@ export default function SshClient({
})
);
if (!override) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(form.getValues())
);
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
form.getValues(),
target.authToken
);
}
};
@@ -625,7 +638,7 @@ export default function SshClient({
{connected && (
<div className="fixed inset-0 z-50 flex flex-col bg-neutral-900">
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
{/* <div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
<Button
size="sm"
variant="destructive"
@@ -633,7 +646,7 @@ export default function SshClient({
>
{t("sshTerminate")}
</Button>
</div>
</div> */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden"

View File

@@ -28,20 +28,18 @@ import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices";
import { useTranslations } from "next-intl";
import {
loadEncryptedLocalStorage,
saveEncryptedLocalStorage
} from "@app/lib/secureLocalStorage";
type VncCredentialsForm = {
password: string;
};
function loadStoredCredentials(key: string): VncCredentialsForm {
try {
const saved = localStorage.getItem(key);
if (saved) return JSON.parse(saved) as VncCredentialsForm;
} catch {
// ignore
}
return { password: "" };
}
const DEFAULT_VNC_CREDENTIALS: VncCredentialsForm = {
password: ""
};
export default function VncClient({
target,
@@ -62,9 +60,25 @@ export default function VncClient({
const form = useForm<VncCredentialsForm>({
resolver: zodResolver(formSchema),
defaultValues: loadStoredCredentials(STORAGE_KEY)
defaultValues: DEFAULT_VNC_CREDENTIALS
});
useEffect(() => {
let cancelled = false;
void loadEncryptedLocalStorage<VncCredentialsForm>(
STORAGE_KEY,
target?.authToken
).then((saved) => {
if (cancelled || !saved) return;
form.reset({ ...DEFAULT_VNC_CREDENTIALS, ...saved });
});
return () => {
cancelled = true;
};
}, [form, target?.authToken]);
const [connected, setConnected] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null);
const rfbRef = useRef<any>(null);
@@ -132,11 +146,11 @@ export default function VncClient({
rfb.resizeSession = true;
rfb.addEventListener("connect", () => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(values));
} catch {
// ignore
}
void saveEncryptedLocalStorage(
STORAGE_KEY,
values,
target.authToken
);
setConnected(true);
});

View File

@@ -48,17 +48,46 @@ export type BrowserGatewayTargetFormProps<T extends FieldValues = FieldValues> =
export function BrowserGatewayTargetForm<T extends FieldValues>(
props: BrowserGatewayTargetFormProps<T>
) {
// IDK MAN REMOVING THIS SEEMS TO CAUSE ISSUES
// Opt out of the React Compiler for this component.
//
// The parent (create page) shares a single `bgTargetForm` instance across
// multiple conditionally-rendered Form sections (SSH passthrough/push, RDP,
// VNC) and calls `bgTargetForm.reset(...)` in a useEffect when the
// resource type changes. react-hook-form's Controller uses an external
// subscription that the React Compiler cannot statically reason about, so
// with `reactCompiler: true` (see next.config.ts) the Compiler can memoize
// the render prop and skip re-rendering the <Input> elements when their
// bound form values change. The visible symptom is that typing into the
// destination/port inputs updates form state but the input itself never
// visually updates. The escape hatch is the canonical fix here.
"use no memo";
const t = useTranslations();
const [siteOpen, setSiteOpen] = useState(false);
const sitesFieldName =
props.multiSite === true ? props.sitesField : props.siteField;
// Subscribe to field values via useWatch and drive the controlled <Input>
// elements from these values rather than from the `field.value` returned
// by the Controller render prop. Combined with the "use no memo" directive
// above, this makes the inputs reliably re-render when their bound form
// values change.
const watchedSites = useWatch({
control: props.control,
name: sitesFieldName
});
const watchedDestination = useWatch({
control: props.control,
name: props.destinationField
});
const watchedDestinationPort = useWatch({
control: props.control,
name: props.destinationPortField
});
const showMultiSiteDisclaimer =
props.multiSite === true &&
((watchedSites as Selectedsite[] | undefined)?.length ?? 0) > 1;
@@ -141,7 +170,17 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
<FormItem>
<FormLabel>{t("destination")}</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
<Input
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestination as
| string
| undefined) ?? ""
}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -158,8 +197,16 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
type="number"
min={1}
max={65535}
{...field}
value={field.value ?? ""}
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
onChange={field.onChange}
value={
(watchedDestinationPort as
| string
| number
| undefined) ?? ""
}
/>
</FormControl>
<FormMessage />

View File

@@ -514,6 +514,16 @@ export default function SitesTable({
)}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link

View File

@@ -123,7 +123,7 @@ export function PolicyAuthStackSectionCreate({
}
allIdps={allIdps}
rolesEditor={
<FormField
<FormField<PolicyFormValues, "roles">
control={parentForm.control}
name="roles"
render={({ field }) => (
@@ -146,7 +146,7 @@ export function PolicyAuthStackSectionCreate({
/>
}
usersEditor={
<FormField
<FormField<PolicyFormValues, "users">
control={parentForm.control}
name="users"
render={({ field }) => (

View File

@@ -725,7 +725,8 @@ export function PolicyAuthStackSectionEdit({
user: headerAuth.user,
password: headerAuth.password,
extendedCompatibility:
headerAuth.extendedCompatibility
headerAuth.extendedCompatibility ??
true
}
: undefined
}

View File

@@ -0,0 +1,124 @@
type EncryptedStorageEnvelope = {
v: 1;
s: string;
i: string;
d: string;
};
const PBKDF2_ITERATIONS = 120000;
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
) as ArrayBuffer;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
async function deriveKey(authToken: string, salt: ArrayBuffer) {
const subtle = window.crypto?.subtle;
if (!subtle) {
throw new Error("Web Crypto is unavailable");
}
const tokenKey = await subtle.importKey(
"raw",
toArrayBuffer(new TextEncoder().encode(authToken)),
"PBKDF2",
false,
["deriveKey"]
);
return subtle.deriveKey(
{
name: "PBKDF2",
salt,
iterations: PBKDF2_ITERATIONS,
hash: "SHA-256"
},
tokenKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
export async function saveEncryptedLocalStorage<T>(
storageKey: string,
value: T,
authToken: string | null | undefined
) {
if (typeof window === "undefined") return;
if (!authToken) {
window.localStorage.removeItem(storageKey);
return;
}
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKey(authToken, toArrayBuffer(salt));
const plaintext = new TextEncoder().encode(JSON.stringify(value));
const encrypted = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(plaintext)
);
const payload: EncryptedStorageEnvelope = {
v: 1,
s: bytesToBase64(salt),
i: bytesToBase64(iv),
d: bytesToBase64(new Uint8Array(encrypted))
};
window.localStorage.setItem(storageKey, JSON.stringify(payload));
}
export async function loadEncryptedLocalStorage<T>(
storageKey: string,
authToken: string | null | undefined
): Promise<T | null> {
if (typeof window === "undefined") return null;
if (!authToken) return null;
const raw = window.localStorage.getItem(storageKey);
if (!raw) return null;
try {
const payload = JSON.parse(raw) as EncryptedStorageEnvelope;
if (payload.v !== 1 || !payload.s || !payload.i || !payload.d) {
throw new Error("Invalid encrypted payload");
}
const salt = base64ToBytes(payload.s);
const iv = base64ToBytes(payload.i);
const data = base64ToBytes(payload.d);
const key = await deriveKey(authToken, toArrayBuffer(salt));
const decrypted = await window.crypto.subtle.decrypt(
{ name: "AES-GCM", iv: toArrayBuffer(iv) },
key,
toArrayBuffer(data)
);
const json = new TextDecoder().decode(decrypted);
return JSON.parse(json) as T;
} catch {
window.localStorage.removeItem(storageKey);
return null;
}
}