Merge branch 'dev' into feat/remember-last-idp-on-smart-login-form

This commit is contained in:
Fred KISSIE
2026-07-10 02:38:38 +02:00
194 changed files with 12909 additions and 4554 deletions
+58
View File
@@ -0,0 +1,58 @@
import type { SiteResourceData } from "@app/lib/privateResourceForm";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
export async function fetchSiteResourceByNiceId(
orgId: string,
niceId: string
): Promise<SiteResourceData | null> {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(
`/org/${orgId}/site-resources?query=${encodeURIComponent(niceId)}&pageSize=50`,
await authCookieHeader()
);
const match = res.data.data.siteResources.find((r) => r.niceId === niceId);
if (!match) {
return null;
}
return {
id: match.siteResourceId,
name: match.name,
orgId,
sites: match.siteIds.map((siteId, idx) => ({
siteId,
siteName: match.siteNames[idx],
siteNiceId: match.siteNiceIds[idx],
online: match.siteOnlines[idx]
})),
mode: match.mode,
scheme: match.scheme,
ssl: match.ssl,
siteNames: match.siteNames,
siteAddresses: match.siteAddresses || null,
siteIds: match.siteIds,
destination: match.destination,
destinationPort: match.destinationPort ?? null,
alias: match.alias || null,
aliasAddress: match.aliasAddress || null,
siteNiceIds: match.siteNiceIds,
niceId: match.niceId,
tcpPortRangeString: match.tcpPortRangeString || null,
udpPortRangeString: match.udpPortRangeString || null,
disableIcmp: match.disableIcmp || false,
authDaemonMode: match.authDaemonMode ?? null,
authDaemonPort: match.authDaemonPort ?? null,
pamMode: match.pamMode ?? null,
subdomain: match.subdomain ?? null,
domainId: match.domainId ?? null,
fullDomain: match.fullDomain ?? null,
labels: match.labels ?? [],
enabled: match.enabled
};
}
+42
View File
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import type {
SidebarNavItem,
SidebarNavSection
} from "@app/components/SidebarNav";
export type FlatNavItem = {
title: string;
href: string;
icon?: ReactNode;
sectionHeading: string;
};
function flattenItems(
items: SidebarNavItem[],
sectionHeading: string,
result: FlatNavItem[]
) {
for (const item of items) {
if (item.href) {
result.push({
title: item.title,
href: item.href,
icon: item.icon,
sectionHeading
});
}
if (item.items?.length) {
flattenItems(item.items, sectionHeading, result);
}
}
}
export function flattenNavSections(
sections: SidebarNavSection[]
): FlatNavItem[] {
const result: FlatNavItem[] = [];
for (const section of sections) {
flattenItems(section.items, section.heading, result);
}
return result;
}
+35
View File
@@ -0,0 +1,35 @@
export type NavHrefParams = {
orgId?: string;
niceId?: string;
resourceId?: string;
userId?: string;
apiKeyId?: string;
clientId?: string;
};
export function hydrateNavHref(
val: string | undefined,
params: NavHrefParams
): string | undefined {
if (!val) return undefined;
return val
.replace("{orgId}", params.orgId ?? "")
.replace("{niceId}", params.niceId ?? "")
.replace("{resourceId}", params.resourceId ?? "")
.replace("{userId}", params.userId ?? "")
.replace("{apiKeyId}", params.apiKeyId ?? "")
.replace("{clientId}", params.clientId ?? "");
}
export function navHrefParamsFromRoute(
params: Record<string, string | string[] | undefined>
): NavHrefParams {
return {
orgId: params.orgId as string | undefined,
niceId: params.niceId as string | undefined,
resourceId: params.resourceId as string | undefined,
userId: params.userId as string | undefined,
apiKeyId: params.apiKeyId as string | undefined,
clientId: params.clientId as string | undefined
};
}
+4 -4
View File
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label"
groupBy: "site" | "label" | "none"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label"
groupBy: "site" | "label" | "none"
): Record<string, boolean> {
return readJson<Record<string, boolean>>(
groupOpenKey(orgId, viewId, groupBy),
@@ -75,7 +75,7 @@ export function readLauncherGroupOpenState(
export function readLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label",
groupBy: "site" | "label" | "none",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label",
groupBy: "site" | "label" | "none",
groupKey: string,
isOpen: boolean
) {
+8 -6
View File
@@ -1,5 +1,12 @@
import type { LauncherResource } from "@server/routers/launcher/types";
export function getPrivateResourceSettingsHref(
orgId: string,
niceId: string
): string {
return `/${orgId}/settings/resources/private/${niceId}/general`;
}
export function getLauncherResourceAdminHref(
orgId: string,
resource: LauncherResource
@@ -8,10 +15,5 @@ export function getLauncherResourceAdminHref(
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
}
const qs = new URLSearchParams({ query: resource.niceId });
if (resource.site?.siteId != null) {
qs.set("siteId", String(resource.site.siteId));
}
return `/${orgId}/settings/resources/private?${qs.toString()}`;
return getPrivateResourceSettingsHref(orgId, resource.niceId);
}
+93
View File
@@ -0,0 +1,93 @@
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
export type PublicAuthState = "protected" | "not_protected" | "none";
const BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
export function derivePublicAuthState(
mode: string | null,
authInfo: Pick<
GetResourceAuthInfoResponse,
"password" | "pincode" | "sso" | "whitelist" | "headerAuth"
>
): PublicAuthState {
if (!BROWSER_MODES.includes(mode || "")) {
return "none";
}
if (
authInfo.password ||
authInfo.pincode ||
authInfo.sso ||
authInfo.whitelist ||
authInfo.headerAuth
) {
return "protected";
}
return "not_protected";
}
export function formatPublicResourceType(
resource: Pick<GetResourceResponse, "mode" | "ssl">
): string {
if (resource.mode === "http") {
return resource.ssl ? "HTTPS" : "HTTP";
}
const mode = (resource.mode || "").toLowerCase();
if (mode === "tcp") {
return "TCP";
}
if (mode === "udp") {
return "UDP";
}
return (resource.mode || "—").toUpperCase();
}
export type PortProtocolState = "all" | "blocked" | "custom";
function getPortProtocolState(
value: string | null | undefined
): PortProtocolState {
if (value === "*") {
return "all";
}
if (!value || value.trim() === "") {
return "blocked";
}
return "custom";
}
export type PortRestrictionDisplay = {
hasNonDefaultPorts: boolean;
tcp: { state: PortProtocolState; ports: string | null };
udp: { state: PortProtocolState; ports: string | null };
};
export function formatPortRestrictionDisplay(
resource: Pick<
GetSiteResourceResponse,
"tcpPortRangeString" | "udpPortRangeString"
>
): PortRestrictionDisplay {
const tcpState = getPortProtocolState(resource.tcpPortRangeString);
const udpState = getPortProtocolState(resource.udpPortRangeString);
return {
hasNonDefaultPorts: tcpState !== "all" || udpState !== "all",
tcp: {
state: tcpState,
ports: tcpState === "custom" ? resource.tcpPortRangeString : null
},
udp: {
state: udpState,
ports: udpState === "custom" ? resource.udpPortRangeString : null
}
};
}
+114
View File
@@ -0,0 +1,114 @@
import type {
LauncherScaleInfo,
LauncherViewConfig
} from "@server/routers/launcher/types";
export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean {
return (
config.query.trim().length > 0 ||
config.siteIds.length > 0 ||
config.labelIds.length > 0
);
}
export function getEffectiveGroupBy(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig["groupBy"] {
if (scale.mode !== "compact") {
return config.groupBy;
}
if (
config.groupBy === "site" &&
config.siteIds.length > 0 &&
scale.capabilities.allowSiteGrouping
) {
return "site";
}
if (
config.groupBy === "label" &&
config.labelIds.length > 0 &&
scale.capabilities.allowLabelGrouping
) {
return "label";
}
return "none";
}
export function getEffectiveLauncherConfig(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): LauncherViewConfig {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === config.groupBy) {
return config;
}
return { ...config, groupBy };
}
export function shouldFetchLauncherGroups(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return false;
}
if (scale.mode === "full") {
return true;
}
return (
(scale.capabilities.allowSiteGrouping &&
groupBy === "site" &&
config.siteIds.length > 0) ||
(scale.capabilities.allowLabelGrouping &&
groupBy === "label" &&
config.labelIds.length > 0)
);
}
export function shouldShowLauncherGroupList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return shouldFetchLauncherGroups(scale, config);
}
export function shouldShowSearchFirstGate(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
return (
scale.mode === "compact" &&
scale.capabilities.requireSearchOrFilter &&
!hasActiveLauncherFilters(config)
);
}
export function shouldShowFlatResourceList(
scale: LauncherScaleInfo,
config: LauncherViewConfig
): boolean {
if (shouldShowSearchFirstGate(scale, config)) {
return false;
}
const groupBy = getEffectiveGroupBy(scale, config);
if (groupBy === "none") {
return true;
}
if (scale.mode === "full") {
return false;
}
return !shouldShowLauncherGroupList(scale, config);
}
+56 -9
View File
@@ -1,21 +1,27 @@
import { internal } from "@app/lib/api";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { shouldFetchLauncherGroups } from "@app/lib/launcherScale";
import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import type {
LauncherGroup,
LauncherScaleInfo,
LauncherDefaultViewOverrides,
LauncherViewConfig,
LauncherViewRecord,
ListLauncherGroupsResponse,
ListLauncherScaleResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import { AxiosResponse } from "axios";
export type LauncherPageData = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -32,19 +38,56 @@ export async function fetchLauncherPageData(
>
): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = [];
let defaultViewOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide: null
};
try {
const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, cookieHeader);
views = viewsRes.data.data.views;
defaultViewOverrides = viewsRes.data.data.defaultViewOverrides;
} catch (e) {}
const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl(
searchParams,
views,
null
null,
defaultViewOverrides
);
const scaleFilters = {
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
};
let scale: LauncherScaleInfo = {
mode: "full",
resourceCount: 0,
siteGroupCount: 0,
labelGroupCount: 0,
capabilities: {
allowSiteGrouping: true,
allowLabelGrouping: true,
requireSearchOrFilter: false
}
};
try {
const sp = buildLauncherSearchParams(scaleFilters, 1);
sp.delete("page");
sp.delete("pageSize");
const scaleRes = await internal.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader);
scale = scaleRes.data.data.scale;
} catch (e) {}
const groupFilters = {
query: config.query,
groupBy: config.groupBy,
@@ -62,20 +105,24 @@ export async function fetchLauncherPageData(
pageSize: 20
};
try {
const sp = buildLauncherSearchParams(groupFilters, 1);
const groupsRes = await internal.get<
AxiosResponse<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
groups = groupsRes.data.data.groups;
groupsPagination = groupsRes.data.data.pagination;
} catch (e) {}
if (shouldFetchLauncherGroups(scale, config)) {
try {
const sp = buildLauncherSearchParams(groupFilters, 1);
const groupsRes = await internal.get<
AxiosResponse<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
groups = groupsRes.data.data.groups;
groupsPagination = groupsRes.data.data.pagination;
} catch (e) {}
}
return {
views,
defaultViewOverrides,
activeViewId,
config,
savedConfig,
scale,
groups,
groupsPagination
};
+21 -7
View File
@@ -1,7 +1,9 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import {
defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig,
parseIdListParam,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
@@ -64,10 +66,13 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId,
views: LauncherViewRecord[]
views: LauncherViewRecord[],
defaultViewOverrides?: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (viewId === "default") {
return defaultLauncherViewConfig;
return defaultViewOverrides
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
: defaultLauncherViewConfig;
}
const savedView = views.find((view) => view.viewId === viewId);
@@ -113,7 +118,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
if (groupBy === "site" || groupBy === "label") {
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
overrides.groupBy = groupBy;
}
@@ -172,7 +177,8 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams,
views: LauncherViewRecord[],
fallbackViewId: LauncherActiveViewId | null
fallbackViewId: LauncherActiveViewId | null,
defaultViewOverrides?: LauncherDefaultViewOverrides
): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams);
@@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl(
: "default";
}
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
const savedConfig = getLauncherUrlBaseConfig(
activeViewId,
views,
defaultViewOverrides
);
let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig(
defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
),
parsed.configOverrides
);
} else if (activeViewId !== "default") {
config = savedConfig;
} else {
config = defaultLauncherViewConfig;
config = getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
);
}
return {
+658
View File
@@ -0,0 +1,658 @@
import { z } from "zod";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh";
export type SiteResourceData = InternalResourceRow & {
enabled: boolean;
};
export type PortMode = "all" | "blocked" | "custom";
const tagSchema = z.object({ id: z.string(), text: z.string() });
export type PrivateResourceAccessTag = z.infer<typeof tagSchema>;
export type PrivateResourceClient = {
clientId: number;
name: string;
};
export type PrivateResourceFormValues = {
name: string;
siteIds: number[];
mode: PrivateResourceMode;
destination: string | null;
alias?: string | null;
niceId?: string;
enabled?: boolean;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | "native" | null;
authDaemonPort?: number | null;
pamMode?: "passthrough" | "push" | null;
destinationPort?: number | null;
scheme?: "http" | "https";
ssl?: boolean;
httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null;
roles?: PrivateResourceAccessTag[];
users?: PrivateResourceAccessTag[];
clients?: PrivateResourceClient[];
};
export type SiteResourceAccess = {
roleIds: number[];
userIds: string[];
clientIds: number[];
};
type TranslateFn = (key: string) => string;
export const isValidPortRangeString = (
val: string | undefined | null
): boolean => {
if (!val || val.trim() === "" || val.trim() === "*") return true;
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") return false;
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
if (!start || !end) return false;
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
if (isNaN(startPort) || isNaN(endPort)) return false;
if (
startPort < 1 ||
startPort > 65535 ||
endPort < 1 ||
endPort > 65535
)
return false;
if (startPort > endPort) return false;
} else {
const port = parseInt(part, 10);
if (isNaN(port) || port < 1 || port > 65535) return false;
}
}
return true;
};
const getPortRangeValidationMessage = (t: TranslateFn) =>
t("editInternalResourceDialogPortRangeValidationError");
export const createPortRangeStringSchema = (t: TranslateFn) =>
z
.string()
.optional()
.nullable()
.refine((val) => isValidPortRangeString(val), {
message: getPortRangeValidationMessage(t)
});
export const getPortModeFromString = (
val: string | undefined | null
): PortMode => {
if (val === "*") return "all";
if (!val || val.trim() === "") return "blocked";
return "custom";
};
export const getPortStringFromMode = (
mode: PortMode,
customValue: string
): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
};
export const isHostname = (destination: string | null): boolean =>
!!destination && /[a-zA-Z]/.test(destination);
export const cleanForFQDN = (name: string): string =>
name
.toLowerCase()
.replace(/[^a-z0-9.-]/g, "-")
.replace(/[-]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/^\.|\.$/g, "");
export function applyAliasAutoGeneration(
values: PrivateResourceFormValues
): PrivateResourceFormValues {
const data = { ...values };
if (
(data.mode === "host" ||
data.mode === "http" ||
(data.mode === "ssh" && data.authDaemonMode !== "native")) &&
isHostname(data.destination)
) {
const currentAlias = data.alias?.trim() || "";
if (!currentAlias) {
let aliasValue = data.destination!;
if (data.destination?.toLowerCase() === "localhost") {
aliasValue = `${cleanForFQDN(data.name)}.internal`;
}
data.alias = aliasValue;
}
}
return data;
}
export function accessTagsToIds(access: {
roles?: PrivateResourceAccessTag[];
users?: PrivateResourceAccessTag[];
clients?: PrivateResourceClient[];
}): SiteResourceAccess {
return {
roleIds: (access.roles ?? []).map((r) => parseInt(r.id)),
userIds: (access.users ?? []).map((u) => u.id),
clientIds: (access.clients ?? []).map((c) => c.clientId)
};
}
export function buildCreateSiteResourcePayload(
values: PrivateResourceFormValues
) {
const data = applyAliasAutoGeneration(values);
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
return {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.destinationPort ?? undefined,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: undefined,
...(!isNativeSsh && {
destinationPort: data.destinationPort ?? undefined
}),
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" &&
data.authDaemonPort != null && {
authDaemonPort: data.authDaemonPort
})
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
userIds: data.users ? data.users.map((u) => u.id) : [],
clientIds: data.clients ? data.clients.map((c) => c.clientId) : []
};
}
export function buildUpdateSiteResourcePayload(
values: PrivateResourceFormValues,
access: SiteResourceAccess
) {
const data = applyAliasAutoGeneration(values);
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
return {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
niceId: data.niceId,
enabled: data.enabled,
...(isNativeSsh
? { destination: null, destinationPort: null }
: { destination: data.destination ?? undefined }),
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
destinationPort: data.destinationPort ?? null,
domainId: data.httpConfigDomainId
? data.httpConfigDomainId
: undefined,
subdomain: data.httpConfigSubdomain
? data.httpConfigSubdomain
: undefined
}),
...(data.mode === "host" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null
})
}),
...(data.mode === "ssh" && {
alias:
data.alias &&
typeof data.alias === "string" &&
data.alias.trim()
? data.alias
: null,
...(!isNativeSsh && {
destinationPort: data.destinationPort ?? null
}),
pamMode: data.pamMode ?? undefined,
...(data.authDaemonMode != null && {
authDaemonMode: data.authDaemonMode
}),
...(data.authDaemonMode === "remote" && {
authDaemonPort: data.authDaemonPort || null
})
}),
...((data.mode === "host" || data.mode === "cidr") && {
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false
}),
roleIds: access.roleIds,
userIds: access.userIds,
clientIds: access.clientIds
};
}
export function inferSshPamMode(
authDaemonMode?: string | null,
pamMode?: "passthrough" | "push" | null
): "passthrough" | "push" {
if (pamMode === "passthrough" || pamMode === "push") {
return pamMode;
}
return authDaemonMode === "remote" ? "push" : "passthrough";
}
export function siteResourceToFormValues(
resource: SiteResourceData
): PrivateResourceFormValues {
return {
name: resource.name,
siteIds: resource.siteIds,
mode: resource.mode ?? "host",
destination: resource.destination ?? "",
alias: resource.alias ?? null,
destinationPort: resource.destinationPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.subdomain ?? null,
httpConfigDomainId: resource.domainId ?? null,
httpConfigFullDomain: resource.fullDomain ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
authDaemonMode:
resource.authDaemonMode === "native"
? "native"
: (resource.authDaemonMode ?? "site"),
authDaemonPort: resource.authDaemonPort ?? null,
pamMode: inferSshPamMode(resource.authDaemonMode, resource.pamMode),
niceId: resource.niceId,
enabled: resource.enabled
};
}
export function createGeneralFormSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")),
niceId: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/)
});
}
export function createAccessFormSchema() {
return z.object({
roles: z.array(tagSchema).optional(),
users: z.array(tagSchema).optional(),
clients: z
.array(
z.object({
clientId: z.number(),
name: z.string()
})
)
.optional()
});
}
export function createCreateFormSchema(t: TranslateFn) {
const destinationRequired = t(
"createInternalResourceDialogDestinationRequired"
);
return z
.object({
name: z
.string()
.min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")),
siteIds: z
.array(z.number().int().positive())
.min(1, t("createInternalResourceDialogPleaseSelectSite")),
mode: z.enum(["host", "cidr", "http", "ssh"]),
destination: z.string().nullish(),
alias: z.string().nullish(),
destinationPort: z
.number()
.int()
.min(1)
.max(65535)
.optional()
.nullable(),
scheme: z.enum(["http", "https"]).optional(),
ssl: z.boolean().optional(),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish(),
authDaemonMode: z
.enum(["site", "remote", "native"])
.optional()
.nullable(),
standardDaemonLocation: z
.enum(["site", "remote"])
.optional()
.nullable(),
authDaemonPort: z.number().int().positive().optional().nullable(),
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional()
})
.superRefine((data, ctx) => {
const isNativeSsh =
data.mode === "ssh" && data.authDaemonMode === "native";
const trimmedDestination = data.destination?.trim();
if (
data.mode !== "ssh" &&
(!trimmedDestination || trimmedDestination.length < 1)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: destinationRequired,
path: ["destination"]
});
}
if (data.mode === "ssh" && !isNativeSsh) {
if (!trimmedDestination || trimmedDestination.length < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: destinationRequired,
path: ["destination"]
});
}
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") {
if (!data.scheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceDownstreamSchemeRequired"),
path: ["scheme"]
});
}
if (
data.destinationPort == null ||
!Number.isFinite(data.destinationPort) ||
data.destinationPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["destinationPort"]
});
}
if (!data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
}
});
}
function destinationRefine(
data: {
mode: PrivateResourceMode;
destination?: string | null;
authDaemonMode?: string | null;
destinationPort?: number | null;
scheme?: string;
httpConfigDomainId?: string | null;
},
ctx: z.RefinementCtx,
t: TranslateFn,
destinationRequired?: string
) {
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
const trimmedDestination = data.destination?.trim();
if (
!isNativeSsh &&
(!trimmedDestination || trimmedDestination.length < 1)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: destinationRequired ?? "Destination is required",
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") {
if (!data.scheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceDownstreamSchemeRequired"),
path: ["scheme"]
});
}
if (
data.destinationPort == null ||
!Number.isFinite(data.destinationPort) ||
data.destinationPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["destinationPort"]
});
}
if (!data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
}
}
export function createHostFormSchema(t: TranslateFn) {
return z
.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("host"),
destination: z.string().nullish(),
alias: z.string().nullish(),
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(),
authDaemonMode: z
.enum(["site", "remote", "native"])
.optional()
.nullable(),
authDaemonPort: z.number().int().positive().optional().nullable()
})
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createCidrFormSchema(t: TranslateFn) {
return z
.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("cidr"),
destination: z.string().nullish(),
tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional()
})
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createHttpFormSchema(t: TranslateFn) {
return z
.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("http"),
destination: z.string().nullish(),
destinationPort: z
.number()
.int()
.min(1)
.max(65535)
.optional()
.nullable(),
scheme: z.enum(["http", "https"]).optional(),
ssl: z.boolean().optional(),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish()
})
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
}
export function createSshFormSchema(
t: TranslateFn,
options?: { isNative?: boolean }
) {
const isNative = options?.isNative ?? false;
return z
.object({
siteIds: z.array(z.number().int().positive()).min(1),
mode: z.literal("ssh"),
destination: z.string().nullish(),
alias: z.string().nullish(),
destinationPort: z
.number()
.int()
.min(1)
.max(65535)
.optional()
.nullable(),
pamMode: z.enum(["passthrough", "push"]),
standardDaemonLocation: z.enum(["site", "remote"]),
authDaemonPort: z.string()
})
.superRefine((data, ctx) => {
destinationRefine(
{
...data,
authDaemonMode: isNative
? "native"
: data.standardDaemonLocation
},
ctx,
t
);
const showDaemonPort =
!isNative &&
data.pamMode === "push" &&
data.standardDaemonLocation === "remote";
if (showDaemonPort) {
const port = Number(data.authDaemonPort);
if (
!data.authDaemonPort.trim() ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("healthCheckPortInvalid"),
path: ["authDaemonPort"]
});
}
}
});
}
export function mergeFormValuesWithResource(
resource: SiteResourceData,
partial: Partial<PrivateResourceFormValues>
): PrivateResourceFormValues {
return {
...siteResourceToFormValues(resource),
...partial
};
}
+143 -7
View File
@@ -7,7 +7,10 @@ import type {
QueryConnectionAuditLogResponse,
QueryRequestAuditLogResponse
} from "@server/routers/auditLogs/types";
import type { ListClientsResponse } from "@server/routers/client";
import type {
ListClientsResponse,
ListUserDevicesResponse
} from "@server/routers/client";
import type {
GetDNSRecordsResponse,
ListDomainsResponse
@@ -25,6 +28,7 @@ import type {
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
ListAllSiteResourcesByOrgResponse,
ListSiteResourceClientsResponse,
ListSiteResourceRolesResponse,
ListSiteResourceUsersResponse
@@ -38,7 +42,7 @@ import {
queryOptions
} from "@tanstack/react-query";
import type { AxiosResponse } from "axios";
import z from "zod";
import z, { meta } from "zod";
import { remote } from "./api";
import { durationToMs } from "./durationToMs";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
@@ -50,11 +54,16 @@ import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
ListLauncherScaleResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
@@ -180,6 +189,38 @@ export const orgQueries = {
return res.data.data.clients;
}
}),
userDevices: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"USER_DEVICES",
{ query, perPage }
] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<ListUserDevicesResponse>
>(`/org/${orgId}/user-devices?${sp.toString()}`, { signal });
return res.data.data.devices;
}
}),
users: ({
orgId,
query,
@@ -324,7 +365,7 @@ export const orgQueries = {
}
}),
resources: ({
proxyResources: ({
orgId,
query,
perPage = 10_000
@@ -334,7 +375,12 @@ export const orgQueries = {
perPage?: number;
}) =>
queryOptions({
queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const,
queryKey: [
"ORG",
orgId,
"PROXY_RESOURCES",
{ query, perPage }
] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
@@ -352,6 +398,39 @@ export const orgQueries = {
}
}),
privateResources: ({
orgId,
query,
perPage = 10_000
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"PRIVATE_RESOURCES",
{ query, perPage }
] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${orgId}/site-resources?${sp.toString()}`, { signal });
return res.data.data.siteResources;
}
}),
healthChecks: ({
orgId,
perPage = 10_000
@@ -1189,13 +1268,13 @@ export const launcherQueries = {
const res = await meta!.api.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, { signal });
return res.data.data.views;
return res.data.data;
}
}),
sites: ({
orgId,
query,
perPage = 500
perPage = 20
}: {
orgId: string;
query?: string;
@@ -1227,7 +1306,7 @@ export const launcherQueries = {
labels: ({
orgId,
query,
perPage = 500
perPage = 20
}: {
orgId: string;
query?: string;
@@ -1298,5 +1377,62 @@ export const launcherQueries = {
const nextPage = page + 1;
return page * pageSize < total ? nextPage : undefined;
}
}),
scale: (orgId: string, filters: LauncherQueryFilters) =>
queryOptions({
queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const,
queryFn: async ({ signal, meta }) => {
const sp = buildLauncherSearchParams(filters, 1);
sp.delete("page");
sp.delete("pageSize");
sp.delete("groupKey");
const res = await meta!.api.get<
AxiosResponse<ListLauncherScaleResponse>
>(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal });
return res.data.data.scale;
}
}),
resourceDetail: (orgId: string, resource: LauncherResource | null) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"LAUNCHER",
"RESOURCE_DETAIL",
resource?.launcherResourceKey ?? null
] as const,
enabled: resource != null,
queryFn: async ({ signal, meta }) => {
if (!resource) {
throw new Error("Resource is required");
}
if (resource.resourceType === "public") {
const res = await meta!.api.get<
AxiosResponse<GetResourceResponse>
>(`/org/${orgId}/resource/${resource.niceId}`, { signal });
const resourceData = res.data.data;
const authRes = await meta!.api.get<
AxiosResponse<GetResourceAuthInfoResponse>
>(`/resource/${resourceData.resourceGuid}/auth`, {
signal
});
return {
resourceType: "public" as const,
data: resourceData,
authInfo: authRes.data.data
};
}
const siteResourceId =
resource.siteResourceId ?? resource.resourceId;
const res = await meta!.api.get<
AxiosResponse<GetSiteResourceResponse>
>(`/org/${orgId}/site-resource/${siteResourceId}`, { signal });
return {
resourceType: "site" as const,
data: res.data.data
};
}
})
};