mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-09 07:30:40 +02:00
Merge branch 'dev' into feat/command-bar
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { cache } from "react";
|
||||
|
||||
export const getBrowserTargetForRequest = cache(async () => {
|
||||
const headersList = await headers();
|
||||
const host = headersList.get("host") || "";
|
||||
const host = headersList.get("p-host") || headersList.get("host") || "";
|
||||
const hostname = host.split(":")[0];
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
export type LauncherActiveViewId = number | "default";
|
||||
|
||||
const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:";
|
||||
const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:";
|
||||
|
||||
function lastViewKey(orgId: string) {
|
||||
return `${LAST_VIEW_PREFIX}${orgId}`;
|
||||
}
|
||||
|
||||
function groupOpenKey(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label" | "none"
|
||||
) {
|
||||
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
|
||||
}
|
||||
|
||||
function readJson<T>(key: string, fallback: T): T {
|
||||
if (typeof window === "undefined") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key: string, value: unknown) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.warn(`Error writing localStorage key "${key}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function readLauncherLastView(
|
||||
orgId: string
|
||||
): LauncherActiveViewId | null {
|
||||
const value = readJson<LauncherActiveViewId | null>(
|
||||
lastViewKey(orgId),
|
||||
null
|
||||
);
|
||||
if (value === "default" || typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function writeLauncherLastView(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId
|
||||
) {
|
||||
writeJson(lastViewKey(orgId), viewId);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpenState(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label" | "none"
|
||||
): Record<string, boolean> {
|
||||
return readJson<Record<string, boolean>>(
|
||||
groupOpenKey(orgId, viewId, groupBy),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
export function readLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label" | "none",
|
||||
groupKey: string,
|
||||
defaultOpen: boolean
|
||||
): boolean {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
return groupKey in state ? state[groupKey] : defaultOpen;
|
||||
}
|
||||
|
||||
export function writeLauncherGroupOpen(
|
||||
orgId: string,
|
||||
viewId: LauncherActiveViewId,
|
||||
groupBy: "site" | "label" | "none",
|
||||
groupKey: string,
|
||||
isOpen: boolean
|
||||
) {
|
||||
const state = readLauncherGroupOpenState(orgId, viewId, groupBy);
|
||||
writeJson(groupOpenKey(orgId, viewId, groupBy), {
|
||||
...state,
|
||||
[groupKey]: isOpen
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export {
|
||||
formatSiteResourceDestinationDisplay,
|
||||
resolveHttpHttpsDisplayPort,
|
||||
type SiteResourceDestinationInput
|
||||
} from "./formatSiteResourceAccess";
|
||||
|
||||
export type PublicResourceAccessInput = {
|
||||
mode: string;
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
proxyPort: number | null;
|
||||
wildcard: boolean;
|
||||
};
|
||||
|
||||
export type SiteResourceAccessInput = {
|
||||
mode: string;
|
||||
destination: string | null;
|
||||
destinationPort: number | null;
|
||||
scheme: "http" | "https" | null;
|
||||
ssl: boolean;
|
||||
fullDomain: string | null;
|
||||
alias: string | null;
|
||||
aliasAddress: string | null;
|
||||
};
|
||||
|
||||
export type LauncherAccessFields = {
|
||||
accessDisplay: string;
|
||||
accessCopyValue: string;
|
||||
accessUrl: string | null;
|
||||
};
|
||||
|
||||
export function formatPublicResourceAccess(
|
||||
resource: PublicResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
const browserModes = ["http", "ssh", "rdp", "vnc"];
|
||||
if (!browserModes.includes(resource.mode)) {
|
||||
const port = resource.proxyPort?.toString() ?? "";
|
||||
return {
|
||||
accessDisplay: port,
|
||||
accessCopyValue: port,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (!resource.fullDomain) {
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: resource.wildcard ? null : url
|
||||
};
|
||||
}
|
||||
|
||||
export function formatSiteResourceAccess(
|
||||
resource: SiteResourceAccessInput
|
||||
): LauncherAccessFields {
|
||||
if (resource.alias) {
|
||||
return {
|
||||
accessDisplay: resource.alias,
|
||||
accessCopyValue: resource.alias,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.mode === "http" && resource.fullDomain) {
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
return {
|
||||
accessDisplay: url,
|
||||
accessCopyValue: url,
|
||||
accessUrl: url
|
||||
};
|
||||
}
|
||||
|
||||
const destination = formatSiteResourceDestinationDisplay({
|
||||
mode: resource.mode as SiteResourceDestinationInput["mode"],
|
||||
destination: resource.destination,
|
||||
destinationPort: resource.destinationPort,
|
||||
scheme: resource.scheme
|
||||
});
|
||||
|
||||
if (destination) {
|
||||
return {
|
||||
accessDisplay: destination,
|
||||
accessCopyValue: destination,
|
||||
accessUrl: resource.mode === "http" ? destination : null
|
||||
};
|
||||
}
|
||||
|
||||
if (resource.aliasAddress) {
|
||||
return {
|
||||
accessDisplay: resource.aliasAddress,
|
||||
accessCopyValue: resource.aliasAddress,
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accessDisplay: "",
|
||||
accessCopyValue: "",
|
||||
accessUrl: null
|
||||
};
|
||||
}
|
||||
|
||||
export function isSafeUrlForLink(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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
|
||||
): string {
|
||||
if (resource.resourceType === "public") {
|
||||
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
|
||||
}
|
||||
|
||||
return getPrivateResourceSettingsHref(orgId, resource.niceId);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { LauncherListQuery } from "@server/routers/launcher/types";
|
||||
|
||||
export type LauncherQueryFilters = {
|
||||
query?: string;
|
||||
groupBy?: LauncherListQuery["groupBy"];
|
||||
groupKey?: string;
|
||||
siteIds?: number[];
|
||||
labelIds?: number[];
|
||||
sort_by?: LauncherListQuery["sort_by"];
|
||||
order?: LauncherListQuery["order"];
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export function buildLauncherSearchParams(
|
||||
filters: LauncherQueryFilters,
|
||||
page: number
|
||||
) {
|
||||
const sp = new URLSearchParams();
|
||||
sp.set("page", String(page));
|
||||
sp.set("pageSize", String(filters.pageSize ?? 20));
|
||||
if (filters.query) {
|
||||
sp.set("query", filters.query);
|
||||
}
|
||||
if (filters.groupBy) {
|
||||
sp.set("groupBy", filters.groupBy);
|
||||
}
|
||||
if (filters.groupKey) {
|
||||
sp.set("groupKey", filters.groupKey);
|
||||
}
|
||||
if (filters.siteIds?.length) {
|
||||
sp.set("siteIds", filters.siteIds.join(","));
|
||||
}
|
||||
if (filters.labelIds?.length) {
|
||||
sp.set("labelIds", filters.labelIds.join(","));
|
||||
}
|
||||
if (filters.sort_by) {
|
||||
sp.set("sort_by", filters.sort_by);
|
||||
}
|
||||
if (filters.order) {
|
||||
sp.set("order", filters.order);
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchLauncherPageData(
|
||||
orgId: string,
|
||||
searchParams: URLSearchParams,
|
||||
cookieHeader: Awaited<
|
||||
ReturnType<typeof import("@app/lib/api/cookies").authCookieHeader>
|
||||
>
|
||||
): 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,
|
||||
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,
|
||||
siteIds: config.siteIds,
|
||||
labelIds: config.labelIds,
|
||||
sort_by: config.sortBy,
|
||||
order: config.order,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
let groups: LauncherGroup[] = [];
|
||||
let groupsPagination: LauncherPageData["groupsPagination"] = {
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
|
||||
import {
|
||||
defaultLauncherViewConfig,
|
||||
getEffectiveDefaultLauncherConfig,
|
||||
parseIdListParam,
|
||||
type LauncherDefaultViewOverrides,
|
||||
type LauncherViewConfig,
|
||||
type LauncherViewRecord
|
||||
} from "@server/routers/launcher/types";
|
||||
import { z } from "zod";
|
||||
|
||||
const launcherUrlBooleanSchema = z
|
||||
.enum(["0", "1"])
|
||||
.transform((value) => value === "1");
|
||||
|
||||
export type LauncherUrlConfigOverrides = Partial<
|
||||
Pick<
|
||||
LauncherViewConfig,
|
||||
| "groupBy"
|
||||
| "layout"
|
||||
| "order"
|
||||
| "showLabels"
|
||||
| "siteIds"
|
||||
| "labelIds"
|
||||
| "query"
|
||||
>
|
||||
>;
|
||||
|
||||
export type ParsedLauncherUrlState = {
|
||||
viewId: LauncherActiveViewId | null;
|
||||
configOverrides: LauncherUrlConfigOverrides;
|
||||
hasAnyLauncherParams: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedLauncherState = {
|
||||
activeViewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
savedConfig: LauncherViewConfig;
|
||||
};
|
||||
|
||||
const LAUNCHER_CONFIG_PARAM_KEYS = [
|
||||
"query",
|
||||
"groupBy",
|
||||
"layout",
|
||||
"order",
|
||||
"showLabels",
|
||||
"siteIds",
|
||||
"labelIds"
|
||||
] as const;
|
||||
|
||||
const LAUNCHER_URL_PARAM_KEYS = [
|
||||
"view",
|
||||
...LAUNCHER_CONFIG_PARAM_KEYS
|
||||
] as const;
|
||||
|
||||
export function hasLauncherConfigParams(searchParams: URLSearchParams) {
|
||||
return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key));
|
||||
}
|
||||
|
||||
export function isLauncherConfigEqual(
|
||||
a: LauncherViewConfig,
|
||||
b: LauncherViewConfig
|
||||
) {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function getLauncherUrlBaseConfig(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[],
|
||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
||||
): LauncherViewConfig {
|
||||
if (viewId === "default") {
|
||||
return defaultViewOverrides
|
||||
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
|
||||
: defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
const savedView = views.find((view) => view.viewId === viewId);
|
||||
return savedView?.config ?? defaultLauncherViewConfig;
|
||||
}
|
||||
|
||||
export function resolveLauncherConfig(
|
||||
baseConfig: LauncherViewConfig,
|
||||
overrides: LauncherUrlConfigOverrides
|
||||
): LauncherViewConfig {
|
||||
return {
|
||||
...baseConfig,
|
||||
...overrides,
|
||||
sortBy: "name"
|
||||
};
|
||||
}
|
||||
|
||||
function parseViewParam(value: string | null): LauncherActiveViewId | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value === "default") {
|
||||
return "default";
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseConfigOverrides(
|
||||
searchParams: URLSearchParams
|
||||
): LauncherUrlConfigOverrides {
|
||||
const overrides: LauncherUrlConfigOverrides = {};
|
||||
|
||||
const query = searchParams.get("query");
|
||||
if (query !== null) {
|
||||
overrides.query = query;
|
||||
}
|
||||
|
||||
const groupBy = searchParams.get("groupBy");
|
||||
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
|
||||
overrides.groupBy = groupBy;
|
||||
}
|
||||
|
||||
const layout = searchParams.get("layout");
|
||||
if (layout === "grid" || layout === "list") {
|
||||
overrides.layout = layout;
|
||||
}
|
||||
|
||||
const order = searchParams.get("order");
|
||||
if (order === "asc" || order === "desc") {
|
||||
overrides.order = order;
|
||||
}
|
||||
|
||||
const showLabels = searchParams.get("showLabels");
|
||||
if (showLabels !== null) {
|
||||
const parsed = launcherUrlBooleanSchema.safeParse(showLabels);
|
||||
if (parsed.success) {
|
||||
overrides.showLabels = parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
const siteIds = searchParams.get("siteIds");
|
||||
if (siteIds !== null) {
|
||||
overrides.siteIds = parseIdListParam(siteIds);
|
||||
}
|
||||
|
||||
const labelIds = searchParams.get("labelIds");
|
||||
if (labelIds !== null) {
|
||||
overrides.labelIds = parseIdListParam(labelIds);
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function parseLauncherUrlState(
|
||||
searchParams: URLSearchParams
|
||||
): ParsedLauncherUrlState {
|
||||
const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) =>
|
||||
searchParams.has(key)
|
||||
);
|
||||
|
||||
return {
|
||||
viewId: parseViewParam(searchParams.get("view")),
|
||||
configOverrides: parseConfigOverrides(searchParams),
|
||||
hasAnyLauncherParams
|
||||
};
|
||||
}
|
||||
|
||||
function isValidActiveViewId(
|
||||
viewId: LauncherActiveViewId,
|
||||
views: LauncherViewRecord[]
|
||||
) {
|
||||
return viewId === "default" || views.some((view) => view.viewId === viewId);
|
||||
}
|
||||
|
||||
export function resolveLauncherStateFromUrl(
|
||||
searchParams: URLSearchParams,
|
||||
views: LauncherViewRecord[],
|
||||
fallbackViewId: LauncherActiveViewId | null,
|
||||
defaultViewOverrides?: LauncherDefaultViewOverrides
|
||||
): ResolvedLauncherState {
|
||||
const parsed = parseLauncherUrlState(searchParams);
|
||||
|
||||
let activeViewId: LauncherActiveViewId = "default";
|
||||
|
||||
if (parsed.viewId !== null) {
|
||||
activeViewId = isValidActiveViewId(parsed.viewId, views)
|
||||
? parsed.viewId
|
||||
: "default";
|
||||
} else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) {
|
||||
activeViewId = isValidActiveViewId(fallbackViewId, views)
|
||||
? fallbackViewId
|
||||
: "default";
|
||||
}
|
||||
|
||||
const savedConfig = getLauncherUrlBaseConfig(
|
||||
activeViewId,
|
||||
views,
|
||||
defaultViewOverrides
|
||||
);
|
||||
|
||||
let config: LauncherViewConfig;
|
||||
if (hasLauncherConfigParams(searchParams)) {
|
||||
config = resolveLauncherConfig(
|
||||
getEffectiveDefaultLauncherConfig(
|
||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
||||
),
|
||||
parsed.configOverrides
|
||||
);
|
||||
} else if (activeViewId !== "default") {
|
||||
config = savedConfig;
|
||||
} else {
|
||||
config = getEffectiveDefaultLauncherConfig(
|
||||
defaultViewOverrides ?? { personal: null, orgWide: null }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
activeViewId,
|
||||
config,
|
||||
savedConfig
|
||||
};
|
||||
}
|
||||
|
||||
function idListsEqual(a: number[], b: number[]) {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
export function serializeLauncherUrlState({
|
||||
viewId,
|
||||
config
|
||||
}: {
|
||||
viewId: LauncherActiveViewId;
|
||||
config: LauncherViewConfig;
|
||||
}): URLSearchParams {
|
||||
const baseConfig = defaultLauncherViewConfig;
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (viewId !== "default") {
|
||||
params.set("view", String(viewId));
|
||||
}
|
||||
|
||||
if (config.query !== baseConfig.query && config.query) {
|
||||
params.set("query", config.query);
|
||||
} else if (config.query !== baseConfig.query && !config.query) {
|
||||
params.set("query", "");
|
||||
}
|
||||
|
||||
if (config.groupBy !== baseConfig.groupBy) {
|
||||
params.set("groupBy", config.groupBy);
|
||||
}
|
||||
|
||||
if (config.layout !== baseConfig.layout) {
|
||||
params.set("layout", config.layout);
|
||||
}
|
||||
|
||||
if (config.order !== baseConfig.order) {
|
||||
params.set("order", config.order);
|
||||
}
|
||||
|
||||
if (config.showLabels !== baseConfig.showLabels) {
|
||||
params.set("showLabels", config.showLabels ? "1" : "0");
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.siteIds, baseConfig.siteIds)) {
|
||||
if (config.siteIds.length > 0) {
|
||||
params.set("siteIds", config.siteIds.join(","));
|
||||
} else {
|
||||
params.set("siteIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
if (!idListsEqual(config.labelIds, baseConfig.labelIds)) {
|
||||
if (config.labelIds.length > 0) {
|
||||
params.set("labelIds", config.labelIds.join(","));
|
||||
} else {
|
||||
params.set("labelIds", "");
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildLauncherPath(orgId: string, params: URLSearchParams) {
|
||||
const query = params.toString();
|
||||
return query ? `/${orgId}?${query}` : `/${orgId}`;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,25 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import { StatusHistoryResponse } from "@server/lib/statusHistory";
|
||||
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
|
||||
import type { GetResourcePolicyResponse } from "@server/routers/policy";
|
||||
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";
|
||||
|
||||
export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
|
||||
export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
|
||||
|
||||
export type ProductUpdate = {
|
||||
link: string | null;
|
||||
@@ -67,6 +86,34 @@ export type LatestVersionResponse = {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
newt: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
cli: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
"panglin-node": {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
windows: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
android: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
mac: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
ios: {
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const productUpdatesQueries = {
|
||||
@@ -1212,3 +1259,180 @@ export const domainQueries = {
|
||||
refetchInterval: durationToMs(10, "seconds")
|
||||
})
|
||||
};
|
||||
|
||||
export const launcherQueries = {
|
||||
views: (orgId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherViewsResponse>
|
||||
>(`/org/${orgId}/launcher/views`, { signal });
|
||||
return res.data.data;
|
||||
}
|
||||
}),
|
||||
sites: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 20
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"LAUNCHER",
|
||||
"SITES",
|
||||
{ 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<ListLauncherSitesResponse>
|
||||
>(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal });
|
||||
return res.data.data.sites;
|
||||
}
|
||||
}),
|
||||
labels: ({
|
||||
orgId,
|
||||
query,
|
||||
perPage = 20
|
||||
}: {
|
||||
orgId: string;
|
||||
query?: string;
|
||||
perPage?: number;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"LAUNCHER",
|
||||
"LABELS",
|
||||
{ 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<ListLauncherLabelsResponse>
|
||||
>(`/org/${orgId}/launcher/labels?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data.labels;
|
||||
}
|
||||
}),
|
||||
groups: (orgId: string, filters: LauncherQueryFilters) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherGroupsResponse>
|
||||
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal });
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
const nextPage = page + 1;
|
||||
return page * pageSize < total ? nextPage : undefined;
|
||||
}
|
||||
}),
|
||||
resources: (
|
||||
orgId: string,
|
||||
filters: LauncherQueryFilters & { groupKey: string }
|
||||
) =>
|
||||
infiniteQueryOptions({
|
||||
queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const,
|
||||
queryFn: async ({ pageParam = 1, signal, meta }) => {
|
||||
const sp = buildLauncherSearchParams(filters, pageParam);
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListLauncherResourcesResponse>
|
||||
>(`/org/${orgId}/launcher/resources?${sp.toString()}`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const { page, pageSize, total } = lastPage.pagination;
|
||||
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
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user