Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot] 4319460fe7 Bump the github-actions-dependencies group across 1 directory with 2 updates
Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/setup-go](https://github.com/actions/setup-go).


Updates `docker/login-action` from 4.2.0 to 4.4.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...af1e73f918a031802d376d3c8bbc3fe56130a9b0)

Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-go
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 01:33:42 +00:00
Owen Schwartz d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen Schwartz 87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen Schwartz 2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
v1rusnl ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl 1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
Owen Schwartz 49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen Schwartz 45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
86 changed files with 3105 additions and 7421 deletions
+5 -5
View File
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Log in to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -264,7 +264,7 @@ jobs:
shell: bash
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version: 1.25
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode
traefik:
image: traefik:v3.6
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service
+1 -1
View File
@@ -50,7 +50,7 @@ services:
- 80:80{{end}}
traefik:
image: docker.io/traefik:v3.6
image: docker.io/traefik:v3.7
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
+5 -37
View File
@@ -266,8 +266,6 @@
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
"resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource",
"resourcePublicCreate": "Create Public Resource",
"resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser",
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
"resourceSeeAll": "See All Resources",
"resourceCreateGeneral": "General",
@@ -866,8 +864,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Password",
"policyAuthHeaderName": "Header name",
"policyAuthHeaderValue": "Expected value",
"policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -2319,12 +2317,6 @@
"createInternalResourceDialogClose": "Close",
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
"privateResourceCreatePageSeeAll": "See All Private Resources",
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
"privateResourceNetworkAccess": "Network Access",
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
"hostSettings": "Host Settings",
"cidrSettings": "CIDR Settings",
"createInternalResourceDialogResourceProperties": "Resource Properties",
"createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site",
@@ -3585,23 +3577,18 @@
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
"resourceLauncherSearchPlaceholder": "Search your resources...",
"resourceLauncherDescription": "View resource details and launch them from one place",
"resourceLauncherSearchPlaceholder": "Search all sites...",
"resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherSaveDefaultPersonal": "Save for Me",
"resourceLauncherResetView": "Reset View",
"resourceLauncherResetSystemDefault": "Reset to System Default",
"resourceLauncherSystemDefaultRestored": "System default restored",
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
"resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter",
"resourceLauncherFilterWithCount": "Filter, {count} applied",
"resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending",
@@ -3609,7 +3596,6 @@
"resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label",
"resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List",
@@ -3617,20 +3603,8 @@
"resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View",
"resourceLauncherDeleteViewTitle": "Delete View",
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
"resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
"resourceLauncherResourceDetails": "Resource Details",
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
"resourceLauncherDownloadClient": "Download client",
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
"resourceLauncherNoPortRestrictions": "No port restrictions",
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherResourceDetailsDescription": "View details for this resource.",
"resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -3639,12 +3613,6 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name",
+1 -11
View File
@@ -18,17 +18,7 @@ export function createPoolConfig(
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
// Allow connections to be released and recreated more aggressively
// to avoid stale connections building up
allowExitOnIdle: false,
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
options: "-c jit=off"
allowExitOnIdle: false
};
}
-1
View File
@@ -232,7 +232,6 @@ export const launcherViews = pgTable("launcherViews", {
}),
name: varchar("name").notNull(),
config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull()
});
-3
View File
@@ -233,9 +233,6 @@ export const launcherViews = sqliteTable("launcherViews", {
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
-32
View File
@@ -327,14 +327,6 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg
);
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get(
"/site-resource/:siteResourceId",
verifySiteResourceAccess,
@@ -478,12 +470,6 @@ authenticated.get(
launcher.listLauncherGroups
);
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get(
"/org/:orgId/launcher/resources",
verifyOrgAccess,
@@ -508,12 +494,6 @@ authenticated.get(
launcher.listLauncherViews
);
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post(
"/org/:orgId/launcher/views",
verifyOrgAccess,
@@ -526,18 +506,6 @@ authenticated.put(
launcher.updateLauncherView
);
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/views/:viewId",
verifyOrgAccess,
@@ -79,8 +79,7 @@ export async function createLauncherView(
),
createdAt: created.createdAt,
updatedAt: created.updatedAt,
isOrgWide: created.userId == null,
isDefault: created.isDefault
isOrgWide: created.userId == null
},
success: true,
error: false,
@@ -1,104 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -46,15 +46,6 @@ export async function deleteLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
-4
View File
@@ -1,6 +1,5 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
@@ -8,6 +7,3 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";
@@ -1,65 +0,0 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -1,137 +0,0 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}
+44 -313
View File
@@ -1,11 +1,9 @@
import { createHash } from "node:crypto";
import { alias, db } from "@server/db";
import { db } from "@server/db";
import {
exitNodes,
labels,
launcherViews,
resourceLabels,
resourcePolicies,
resources,
rolePolicies,
roleResources,
@@ -32,17 +30,14 @@ import {
inArray,
isNull,
like,
not,
or,
sql,
type SQL
sql
} from "drizzle-orm";
import {
formatPublicResourceAccess,
formatSiteResourceAccess
} from "./formatLauncherAccess";
import {
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
@@ -64,68 +59,6 @@ export type AccessibleIds = {
};
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
type LauncherResourcesCacheEntry = {
items: LauncherResource[];
total: number;
};
type LauncherGroupsCacheEntry = {
groups: LauncherGroup[];
total: number;
};
function launcherListQueryHash(
userRoleIds: number[],
query: LauncherListQuery,
extra?: Record<string, string>
) {
const payload = JSON.stringify({
roles: [...userRoleIds].sort((a, b) => a - b),
query: query.query,
groupBy: query.groupBy,
siteIds: query.siteIds ?? "",
labelIds: query.labelIds ?? "",
sort_by: query.sort_by,
order: query.order,
...extra
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function launcherResourcesQueryHash(
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
) {
return launcherListQueryHash(userRoleIds, query, {
groupKey: query.groupKey
});
}
function launcherGroupsQueryHash(
userRoleIds: number[],
query: LauncherListQuery
) {
return launcherListQueryHash(userRoleIds, query);
}
function launcherResourcesCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:results:${orgId}:${userId}:${queryHash}`;
}
function launcherGroupsCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
}
function launcherAccessibleIdsCacheKey(
orgId: string,
@@ -136,48 +69,6 @@ function launcherAccessibleIdsCacheKey(
return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`;
}
async function fetchOrgNonSsoPublicResourceIds(
orgId: string
): Promise<number[]> {
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const effectiveSso = sql<boolean>`
COALESCE(
CASE
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
ELSE ${defaultPolicy.sso}
END,
false
)
`;
const rows = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(
sharedPolicy,
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
defaultPolicy,
eq(
defaultPolicy.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
.where(
and(
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.blockAccess, false),
not(effectiveSso)
)
);
return rows.map((row) => row.resourceId);
}
async function resolveAccessibleIdsUncached(
orgId: string,
userId: string,
@@ -189,8 +80,7 @@ async function resolveAccessibleIdsUncached(
directPolicyResourceResults,
rolePolicyResourceResults,
directSiteResourceResults,
roleSiteResourceResults,
orgNonSsoPublicResources
roleSiteResourceResults
] = await Promise.all([
db
.select({ resourceId: userResources.resourceId })
@@ -259,8 +149,7 @@ async function resolveAccessibleIdsUncached(
})
.from(roleSiteResources)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]),
fetchOrgNonSsoPublicResourceIds(orgId)
: Promise.resolve([])
]);
return {
@@ -269,8 +158,7 @@ async function resolveAccessibleIdsUncached(
...directResources.map((r) => r.resourceId),
...roleResourceResults.map((r) => r.resourceId),
...directPolicyResourceResults.map((r) => r.resourceId),
...rolePolicyResourceResults.map((r) => r.resourceId),
...orgNonSsoPublicResources
...rolePolicyResourceResults.map((r) => r.resourceId)
])
),
siteResourceIds: Array.from(
@@ -306,21 +194,6 @@ function searchPattern(query: string) {
return `%${query.trim()}%`;
}
function combineOrConditions(
...conditions: (SQL | undefined)[]
): SQL | undefined {
const parts = conditions.filter(
(condition): condition is SQL => !!condition
);
if (parts.length === 0) {
return undefined;
}
if (parts.length === 1) {
return parts[0];
}
return or(...parts);
}
function buildSearchConditionForPublic(
query: string,
labelsFeatureEnabled: boolean
@@ -332,17 +205,7 @@ function buildSearchConditionForPublic(
const queryList = [
like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
inArray(
resources.resourceId,
db
.select({ id: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
)
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern)
];
if (labelsFeatureEnabled) {
@@ -375,11 +238,6 @@ function buildSearchConditionForSiteResource(
const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
pattern
),
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
@@ -404,79 +262,6 @@ function buildSearchConditionForSiteResource(
return or(...queryList);
}
async function filterPublicResourceIdsByTextSearch(
orgId: string,
resourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return resourceIds;
}
const rows = await db
.selectDistinct({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
textMatch
)
);
return rows.map((row) => row.resourceId);
}
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string,
labelsFeatureEnabled: boolean
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return siteResourceIds;
}
const rows = await db
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
textMatch
)
);
return rows.map((row) => row.siteResourceId);
}
async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
@@ -803,8 +588,9 @@ async function listSiteGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups,
groups: groups.slice(offset, offset + query.pageSize),
total
};
}
@@ -1002,53 +788,26 @@ async function listLabelGroups(
});
const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return {
groups,
groups: groups.slice(offset, offset + query.pageSize),
total
};
}
async function listLauncherGroupsForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<LauncherGroupsCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
return listSiteGroups(orgId, accessible, query);
}
export async function listLauncherGroupsForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> {
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
let result = cached;
if (!result) {
result = await listLauncherGroupsForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
const offset = (query.page - 1) * query.pageSize;
return {
groups: result.groups.slice(offset, offset + query.pageSize),
total: result.total
};
return listSiteGroups(orgId, accessible, query);
}
async function mapPublicResources(
@@ -1259,6 +1018,26 @@ function filterResourcesByLabel(
);
}
function filterResourcesBySearch(
items: LauncherResource[],
query: string
): LauncherResource[] {
if (!query.trim()) {
return items;
}
const pattern = query.trim().toLowerCase();
return items.filter(
(item) =>
item.name.toLowerCase().includes(pattern) ||
item.accessDisplay.toLowerCase().includes(pattern) ||
item.accessCopyValue.toLowerCase().includes(pattern) ||
item.labels.some((label) =>
label.name.toLowerCase().includes(pattern)
) ||
item.site?.name.toLowerCase().includes(pattern)
);
}
function sortLauncherResources(
items: LauncherResource[],
order: "asc" | "desc"
@@ -1271,12 +1050,12 @@ function sortLauncherResources(
});
}
async function listLauncherResourcesForUserUncached(
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<LauncherResourcesCacheEntry> {
): Promise<{ resources: LauncherResource[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1349,27 +1128,6 @@ async function listLauncherResourcesForUserUncached(
}
}
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query,
labelsFeatureEnabled
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query,
labelsFeatureEnabled
);
}
}
const labelMaps = await fetchLabelsForResources(
orgId,
filteredResourceIds,
@@ -1401,48 +1159,21 @@ async function listLauncherResourcesForUserUncached(
]);
let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
}
if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey);
}
items = sortLauncherResources(items, query.order);
return {
items,
total: items.length
};
}
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
let result = cached;
if (!result) {
result = await listLauncherResourcesForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
}
const total = items.length;
const offset = (query.page - 1) * query.pageSize;
return {
resources: result.items.slice(offset, offset + query.pageSize),
total: result.total
resources: items.slice(offset, offset + query.pageSize),
total
};
}
-133
View File
@@ -1,133 +0,0 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}
@@ -1,60 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+17 -6
View File
@@ -4,10 +4,22 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import {
extractDefaultViewOverrides,
listVisibleLauncherViews
} from "./launcherDefaultView";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
export async function listLauncherViews(
req: Request,
@@ -39,8 +51,7 @@ export async function listLauncherViews(
return response(res, {
data: {
views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
views: rows.map(mapViewRow)
},
success: true,
error: false,
+4 -55
View File
@@ -2,10 +2,9 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label", "none"]).default("site"),
groupBy: z.enum(["site", "label"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"),
@@ -89,31 +88,10 @@ export type LauncherViewRecord = {
createdAt: string;
updatedAt: string;
isOrgWide: boolean;
isDefault: boolean;
};
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
};
export const launcherFilterListQuerySchema = z.strictObject({
@@ -122,8 +100,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int()
.positive()
.optional()
.catch(20)
.default(20),
.catch(500)
.default(500),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
@@ -160,7 +138,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupBy: z.enum(["site", "label"]).optional().default("site"),
groupKey: z.string().optional(),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
@@ -185,32 +163,3 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection =
| { type: "default" }
| { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
+1 -11
View File
@@ -66,15 +66,6 @@ export async function updateLauncherView(
);
}
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission(
@@ -144,8 +135,7 @@ export async function updateLauncherView(
),
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null,
isDefault: updated.isDefault
isOrgWide: updated.userId == null
},
success: true,
error: false,
@@ -1,82 +0,0 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+4 -4
View File
@@ -616,8 +616,8 @@ export async function listResources(
and(
inArray(resources.mode, browserGatewayModes),
or(
effectiveSso,
effectiveWhitelist,
eq(effectiveSso, true),
eq(effectiveWhitelist, true),
not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId))
@@ -629,8 +629,8 @@ export async function listResources(
conditions.push(
and(
inArray(resources.mode, browserGatewayModes),
not(effectiveSso),
not(effectiveWhitelist),
not(eq(effectiveSso, true)),
not(eq(effectiveWhitelist, true)),
isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId),
isNull(effectivePasswordId)
@@ -55,7 +55,7 @@ const createSiteResourceSchema = z
siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(),
destination: z.string().min(1).optional(),
enabled: z.boolean().default(true),
alias: z
.string()
@@ -54,7 +54,7 @@ const updateSiteResourceSchema = z
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(),
destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).nullish(),
destination: z.string().min(1).optional(),
enabled: z.boolean().optional(),
alias: z
.string()
-2
View File
@@ -82,11 +82,9 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId}
isAdmin={isAdminOrOwner}
views={launcherData.views}
defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
scale={launcherData.scale}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
/>
@@ -15,7 +15,6 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -344,10 +343,7 @@ export default function GeneralPage() {
<Link
href={
row.original.siteResourceId != null
? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
}
>
@@ -11,7 +11,6 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -324,10 +323,7 @@ export default function ConnectionLogsPage() {
if (row.original.resourceName && row.original.resourceNiceId) {
return (
<Link
href={getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)}
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
@@ -9,7 +9,6 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
@@ -396,10 +395,7 @@ export default function GeneralPage() {
<Link
href={
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
}
onClick={(e) => e.stopPropagation()}
@@ -1,112 +0,0 @@
"use client";
import { MachinesSelector } from "@app/components/machines-selector";
import { RolesSelector } from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
import type { Tag } from "@app/components/tags/tag-input";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
import type { Control } from "react-hook-form";
type AccessFormValues = {
roles?: Tag[];
users?: Tag[];
clients?: PrivateResourceClient[];
};
type PrivateResourceAccessFieldsProps = {
control: Control<AccessFormValues>;
orgId: string;
loading?: boolean;
hasMachineClients?: boolean;
};
export function PrivateResourceAccessFields({
control,
orgId,
loading = false,
hasMachineClients = false
}: PrivateResourceAccessFieldsProps) {
const t = useTranslations();
if (loading) {
return (
<div className="text-sm text-muted-foreground">{t("loading")}</div>
);
}
return (
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormControl>
<RolesSelector
selectedRoles={field.value ?? []}
orgId={orgId}
restrictAdminRole
onSelectRoles={(newRoles) => {
field.onChange(newRoles);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel>
<UsersSelector
selectedUsers={field.value ?? []}
orgId={orgId}
onSelectUsers={(newUsers) => {
field.onChange(newUsers);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{hasMachineClients && (
<SettingsFormCell span="full">
<FormField
control={control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("machineClients")}</FormLabel>
<MachinesSelector
selectedMachines={field.value ?? []}
orgId={orgId}
onSelectMachines={(machines) => {
field.onChange(machines);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
);
}
@@ -1,175 +0,0 @@
"use client";
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import type { Control, UseFormWatch } from "react-hook-form";
type PrivateResourceAliasFieldProps = {
control: Control<any>;
watch: UseFormWatch<any>;
labelPrefix?: "create" | "edit";
disabled?: boolean;
};
export function PrivateResourceAliasField({
control,
watch,
labelPrefix = "edit",
disabled = false
}: PrivateResourceAliasFieldProps) {
const t = useTranslations();
const aliasLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogAlias"
: "editInternalResourceDialogAlias";
const aliasDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogAliasDescription"
: "editInternalResourceDialogAliasDescription";
const aliasValue = watch("alias");
const aliasEndsWithLocal =
typeof aliasValue === "string" &&
aliasValue.trim().toLowerCase().endsWith(".local");
return (
<FormField
control={control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t(aliasLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
/>
</FormControl>
{aliasEndsWithLocal && (
<p className="text-xs text-amber-700/80 mt-1">
{t("internalResourceAliasLocalWarning")}
</p>
)}
<FormMessage />
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
</FormItem>
)}
/>
);
}
type PrivateResourceHostDestinationFieldsProps = {
control: Control<any>;
watch: UseFormWatch<any>;
labelPrefix?: "create" | "edit";
hideAlias?: boolean;
};
export function PrivateResourceHostDestinationFields({
control,
watch,
labelPrefix = "edit",
hideAlias = false
}: PrivateResourceHostDestinationFieldsProps) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationField = (
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
if (hideAlias) {
return destinationField;
}
return (
<SettingsFormGrid>
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
/>
</SettingsFormCell>
</SettingsFormGrid>
);
}
export function PrivateResourceCidrDestinationField({
control,
labelPrefix = "edit"
}: {
control: Control<any>;
labelPrefix?: "create" | "edit";
}) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
return (
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
@@ -1,327 +0,0 @@
"use client";
import CertificateStatus from "@app/components/CertificateStatus";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { build } from "@server/build";
import { useTranslations } from "next-intl";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
type PrivateResourceHttpFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
orgId: string;
watch: UseFormWatch<any>;
disabled?: boolean;
siteResourceId?: number;
resourceDomainId?: string | null;
resourceFullDomain?: string | null;
labelPrefix?: "create" | "edit";
hideDomainPicker?: boolean;
hidePaidFeaturesAlert?: boolean;
};
export function PrivateResourceHttpFields({
control,
setValue,
orgId,
watch,
disabled = false,
siteResourceId,
resourceDomainId,
resourceFullDomain,
labelPrefix = "edit",
hideDomainPicker = false,
hidePaidFeaturesAlert = false
}: PrivateResourceHttpFieldsProps) {
const t = useTranslations();
const schemeLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogScheme"
: "editInternalResourceDialogScheme";
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationPortLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const httpConfigurationTitleKey =
labelPrefix === "create"
? "createInternalResourceDialogHttpConfiguration"
: "editInternalResourceDialogHttpConfiguration";
const httpConfigurationDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const enableSslLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogEnableSsl"
: "editInternalResourceDialogEnableSsl";
const enableSslDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogEnableSslDescription"
: "editInternalResourceDialogEnableSslDescription";
const httpConfigSubdomain = watch("httpConfigSubdomain");
const httpConfigDomainId = watch("httpConfigDomainId");
const httpConfigFullDomain = watch("httpConfigFullDomain");
const ssl = watch("ssl");
return (
<SettingsFormGrid>
{!hidePaidFeaturesAlert && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="quarter">
<FormField
control={control}
name="scheme"
render={({ field }) => (
<FormItem>
<FormLabel>{t(schemeLabelKey)}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? "http"}
disabled={disabled}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="quarter">
<FormField
control={control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={field.value ?? ""}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
field.onChange(null);
return;
}
const n = Number(raw);
field.onChange(
Number.isFinite(n) ? n : null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{!hideDomainPicker && (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(httpConfigurationTitleKey)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(httpConfigurationDescriptionKey)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<div
className={
disabled
? "pointer-events-none opacity-50"
: undefined
}
>
<DomainPicker
key={
siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
setValue("httpConfigSubdomain", null);
setValue("httpConfigDomainId", null);
setValue("httpConfigFullDomain", null);
return;
}
setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
setValue(
"httpConfigDomainId",
res.domainId
);
setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
</div>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="private-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsFormCell>
{siteResourceId &&
resourceDomainId &&
httpConfigFullDomain &&
httpConfigDomainId === resourceDomainId &&
httpConfigFullDomain === resourceFullDomain &&
build != "oss" &&
ssl && (
<SettingsFormCell span="half">
<div className="flex items-center gap-2 pt-1">
<span className="text-sm font-medium">
{t("certificateStatus")}:
</span>
<CertificateStatus
orgId={orgId}
domainId={resourceDomainId}
fullDomain={httpConfigFullDomain}
autoFetch={true}
showLabel={false}
polling={true}
/>
</div>
</SettingsFormCell>
)}
</>
)}
{hideDomainPicker && (
<SettingsFormCell span="half">
<FormField
control={control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="private-resource-ssl"
label={t(enableSslLabelKey)}
description={t(enableSslDescriptionKey)}
checked={!!field.value}
onCheckedChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
);
}
@@ -1,24 +0,0 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
export function PrivateResourceMultiSiteRoutingHelp() {
const t = useTranslations();
return (
<p className="text-sm text-muted-foreground mt-2">
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
);
}
@@ -1,300 +0,0 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
getPortModeFromString,
getPortStringFromMode,
type PortMode
} from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
import { useEffect, useState, type ReactNode } from "react";
import type { Control, UseFormSetValue } from "react-hook-form";
type PrivateResourceNetworkAccessFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
showPortRanges?: boolean;
initialTcp?: string | null;
initialUdp?: string | null;
disabled?: boolean;
icmpId?: string;
embedInParentGrid?: boolean;
};
export function PrivateResourceAllowIcmpField({
control,
id = "private-resource-allow-icmp",
disabled = false
}: {
control: Control<any>;
id?: string;
disabled?: boolean;
}) {
const t = useTranslations();
return (
<FormField
control={control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id={id}
label={t("privateResourceAllowIcmpPing")}
checked={!field.value}
onCheckedChange={(checked) =>
field.onChange(!checked)
}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
function PrivateResourceNetworkAccessHeader() {
const t = useTranslations();
return (
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("privateResourceNetworkAccess")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("privateResourceNetworkAccessDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
);
}
export function PrivateResourceNetworkAccessFields({
control,
setValue,
showPortRanges = true,
initialTcp,
initialUdp,
disabled = false,
icmpId = "private-resource-allow-icmp",
embedInParentGrid = false
}: PrivateResourceNetworkAccessFieldsProps) {
const t = useTranslations();
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
getPortModeFromString(initialTcp)
);
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
getPortModeFromString(initialUdp)
);
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
initialTcp && initialTcp !== "*" ? initialTcp : ""
);
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
initialUdp && initialUdp !== "*" ? initialUdp : ""
);
useEffect(() => {
if (!showPortRanges) return;
setValue(
"tcpPortRangeString",
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
);
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
useEffect(() => {
if (!showPortRanges) return;
setValue(
"udpPortRangeString",
getPortStringFromMode(udpPortMode, udpCustomPorts)
);
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
const content: ReactNode = (
<>
<PrivateResourceNetworkAccessHeader />
{showPortRanges ? (
<>
<SettingsFormCell span="full">
<FormField
control={control}
name="tcpPortRangeString"
render={() => (
<FormItem>
<FormLabel>
{t("editInternalResourceDialogTcp")}
</FormLabel>
<div className="flex items-center gap-2">
<Select
value={tcpPortMode}
onValueChange={(v: PortMode) =>
setTcpPortMode(v)
}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
className="flex-1"
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(
e.target.value
)
}
/>
</FormControl>
) : (
<Input
className="flex-1"
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={control}
name="udpPortRangeString"
render={() => (
<FormItem>
<FormLabel>
{t("editInternalResourceDialogUdp")}
</FormLabel>
<div className="flex items-center gap-2">
<Select
value={udpPortMode}
onValueChange={(v: PortMode) =>
setUdpPortMode(v)
}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
className="flex-1"
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(
e.target.value
)
}
/>
</FormControl>
) : (
<Input
className="flex-1"
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</>
) : null}
<SettingsFormCell span="full">
<PrivateResourceAllowIcmpField
control={control}
id={icmpId}
disabled={disabled}
/>
</SettingsFormCell>
</>
);
if (embedInParentGrid) {
return content;
}
return <SettingsFormGrid>{content}</SettingsFormGrid>;
}
export function PrivateResourcePortRanges(
props: Omit<
PrivateResourceNetworkAccessFieldsProps,
"showPortRanges" | "embedInParentGrid"
>
) {
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
}
@@ -1,133 +0,0 @@
"use client";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "@app/components/multi-site-selector";
import { SitesSelector } from "@app/components/site-selector";
import type { Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { cn } from "@app/lib/cn";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>;
orgId: string;
selectedSites: Selectedsite[];
onSelectedSitesChange: (sites: Selectedsite[]) => void;
siteIdsFieldName?: FieldPath<T>;
singleSite?: boolean;
};
export function PrivateResourceSitesField<T extends FieldValues>({
control,
orgId,
selectedSites,
onSelectedSitesChange,
siteIdsFieldName = "siteIds" as FieldPath<T>,
singleSite = false
}: PrivateResourceSitesFieldProps<T>) {
const t = useTranslations();
return (
<FormField
control={control}
name={siteIdsFieldName}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("sites")}</FormLabel>
{singleSite ? (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{selectedSites[0]?.name ??
t("selectSite")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedSites[0] ?? null}
filterTypes={["newt"]}
onSelectSite={(site) => {
onSelectedSitesChange([site]);
field.onChange([site.siteId]);
}}
/>
</PopoverContent>
</Popover>
) : (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<MultiSitesSelector
orgId={orgId}
selectedSites={selectedSites}
filterTypes={["newt"]}
onSelectionChange={(sites) => {
onSelectedSitesChange(sites);
field.onChange(
sites.map((s) => s.siteId)
);
}}
/>
</PopoverContent>
</Popover>
)}
<FormMessage />
{!singleSite && selectedSites.length > 1 ? (
<PrivateResourceMultiSiteRoutingHelp />
) : null}
</FormItem>
)}
/>
);
}
@@ -1,333 +0,0 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useState, type ReactNode } from "react";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
import type { Selectedsite } from "@app/components/site-selector";
type PrivateResourceSshFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
watch: UseFormWatch<any>;
orgId?: string;
disabled?: boolean;
selectedSites: Selectedsite[];
onSelectedSitesChange: (sites: Selectedsite[]) => void;
labelPrefix?: "create" | "edit";
showSshSettings?: boolean;
layout?: "default" | "wizard";
showPaidFeaturesAlert?: boolean;
hideAlias?: boolean;
embedInParentGrid?: boolean;
isNativeSsh?: boolean;
};
export function PrivateResourceSshFields({
control,
setValue,
watch,
orgId,
disabled = false,
selectedSites,
onSelectedSitesChange,
labelPrefix = "edit",
showSshSettings = true,
layout = "default",
showPaidFeaturesAlert = true,
hideAlias = false,
embedInParentGrid = false,
isNativeSsh: isNativeSshProp
}: PrivateResourceSshFieldsProps) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationPortLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const authDaemonMode = watch("authDaemonMode") ?? "site";
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
const standardDaemonLocation =
watch("standardDaemonLocation") ??
(authDaemonMode === "remote" ? "remote" : "site");
const formAuthDaemonPort = watch("authDaemonPort");
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
);
const isEditLayout = layout === "default";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
() => (authDaemonMode === "native" ? "native" : "standard")
);
const isNative =
isNativeSshProp ??
(isEditLayout
? authDaemonMode === "native"
: sshServerMode === "native");
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
isNative,
authDaemonMode,
pamMode
);
function trimSitesToFirst() {
if (selectedSites.length <= 1) return;
const first = selectedSites.slice(0, 1);
onSelectedSitesChange(first);
setValue(
"siteIds",
first.map((s: Selectedsite) => s.siteId),
{ shouldValidate: true }
);
}
function handlePamModeChange(value: "passthrough" | "push") {
if (disabled) return;
setValue("pamMode", value, { shouldValidate: true });
if (value === "passthrough") {
setValue("authDaemonPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
return;
}
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
trimSitesToFirst();
}
}
function handleDaemonLocationChange(value: "site" | "remote") {
if (disabled) return;
setValue("standardDaemonLocation", value, { shouldValidate: true });
setValue("authDaemonMode", value, { shouldValidate: true });
if (value === "site") {
setValue("authDaemonPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
trimSitesToFirst();
}
}
function handleAuthDaemonPortChange(value: string) {
if (disabled) return;
setAuthDaemonPortInput(value);
const trimmed = value.trim();
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
shouldValidate: true
});
}
function handleServerModeChange(mode: "standard" | "native") {
if (disabled) return;
setSshServerMode(mode);
if (mode === "native") {
setValue("authDaemonMode", "native", { shouldValidate: true });
setValue("authDaemonPort", null, { shouldValidate: true });
setValue("destination", null, { shouldValidate: true });
setValue("destinationPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
trimSitesToFirst();
return;
}
setValue("authDaemonMode", standardDaemonLocation, {
shouldValidate: true
});
setValue("destinationPort", 22, { shouldValidate: true });
}
const aliasField = hideAlias ? null : (
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
disabled={disabled}
/>
);
const standardSshTargetRow =
orgId && !isNative ? (
<div className="grid grid-cols-3 gap-4 items-start">
<PrivateResourceSitesField
control={control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={onSelectedSitesChange}
singleSite={!useMultiSiteTargetForm}
/>
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={field.value ?? ""}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
field.onChange(null);
return;
}
const n = Number(raw);
field.onChange(
Number.isFinite(n) ? n : null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null;
const sshSettingsFields = showSshSettings ? (
<SshServerSettingsFields
idPrefix={
layout === "wizard"
? "private-ssh-create"
: "private-ssh-fields"
}
pamMode={pamMode}
standardDaemonLocation={standardDaemonLocation}
authDaemonPort={authDaemonPortInput}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={handleDaemonLocationChange}
onAuthDaemonPortChange={handleAuthDaemonPortChange}
sshServerMode={sshServerMode}
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
onServerModeChange={handleServerModeChange}
/>
) : null;
const destinationSection = (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative && orgId ? (
<>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={onSelectedSitesChange}
singleSite
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
disabled={disabled}
/>
</SettingsFormCell>
</>
) : null}
{!isNative && orgId ? (
<SettingsFormCell span="full">
{standardSshTargetRow}
</SettingsFormCell>
) : null}
{!isNative && !hideAlias ? (
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
) : null}
</>
);
const content: ReactNode = (
<>
{showPaidFeaturesAlert && layout === "default" && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
{sshSettingsFields}
{destinationSection}
</>
);
if (embedInParentGrid) {
return content;
}
return <SettingsFormGrid>{content}</SettingsFormGrid>;
}
@@ -1,170 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
accessTagsToIds,
buildUpdateSiteResourcePayload,
createAccessFormSchema,
mergeFormValuesWithResource
} from "@app/lib/privateResourceForm";
import { resourceQueries, orgQueries } from "@app/lib/queries";
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const { siteResource, setAccess } = useSiteResourceContext();
const { loading, roles, users, clients, hasMachineClients } =
useAccessFormDefaults(siteResource.orgId, siteResource.id);
const machineClientsQuery = useQuery(
orgQueries.machineClients({
orgId: siteResource.orgId,
perPage: 1
})
);
const hasMachineClientsResolved =
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
const form = useForm({
resolver: zodResolver(createAccessFormSchema()),
defaultValues: {
roles: [] as typeof roles,
users: [] as typeof users,
clients: [] as typeof clients
}
});
useEffect(() => {
if (!loading) {
form.reset({ roles, users, clients });
}
}, [loading, roles, users, clients, form]);
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
const access = accessTagsToIds({
roles: data.roles,
users: data.users,
clients: data.clients
});
const merged = mergeFormValuesWithResource(siteResource, {});
const payload = buildUpdateSiteResourcePayload(merged, access);
try {
await api.post(`/site-resource/${siteResource.id}`, payload);
setAccess(access);
await Promise.all([
queryClient.invalidateQueries(
resourceQueries.siteResourceRoles({
siteResourceId: siteResource.id
})
),
queryClient.invalidateQueries(
resourceQueries.siteResourceUsers({
siteResourceId: siteResource.id
})
),
queryClient.invalidateQueries(
resourceQueries.siteResourceClients({
siteResourceId: siteResource.id
})
)
]);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
)
});
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
}
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authentication")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-access-form"
>
<PrivateResourceAccessFields
control={form.control}
orgId={siteResource.orgId}
loading={loading}
hasMachineClients={
hasMachineClients ||
hasMachineClientsResolved
}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-access-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,138 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceCidrPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "cidr",
destination: siteResource.destination ?? "",
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
disableIcmp: siteResource.disableIcmp ?? false
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "cidr",
destination: data.destination,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("cidrSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationCidrDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-cidr-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceCidrDestinationField
control={asAnyControl(form.control)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
initialTcp={
siteResource.tcpPortRangeString
}
initialUdp={
siteResource.udpPortRangeString
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-cidr-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,133 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: siteResource.name,
niceId: siteResource.niceId
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
name: data.name,
niceId: data.niceId
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-general-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-general-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,147 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createHostFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHostPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "host",
destination: siteResource.destination ?? "",
alias: siteResource.alias ?? null,
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
disableIcmp: siteResource.disableIcmp ?? false,
authDaemonMode: siteResource.authDaemonMode ?? "site",
authDaemonPort: siteResource.authDaemonPort ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "host",
destination: data.destination,
alias: data.alias,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp,
authDaemonMode: data.authDaemonMode,
authDaemonPort: data.authDaemonPort
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-host-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHostDestinationFields
control={asAnyControl(form.control)}
watch={asAnyWatch(form.watch)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
initialTcp={
siteResource.tcpPortRangeString
}
initialUdp={
siteResource.udpPortRangeString
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-host-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,152 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHttpPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "http",
destination: siteResource.destination ?? "",
destinationPort: siteResource.destinationPort ?? null,
scheme: siteResource.scheme ?? "http",
ssl: siteResource.ssl ?? false,
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigFullDomain: siteResource.fullDomain ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "http",
destination: data.destination,
destinationPort: data.destinationPort,
scheme: data.scheme,
ssl: data.ssl,
httpConfigSubdomain: data.httpConfigSubdomain,
httpConfigDomainId: data.httpConfigDomainId,
httpConfigFullDomain: data.httpConfigFullDomain
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogHttpConfigurationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-http-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
orgId={siteResource.orgId}
watch={asAnyWatch(form.watch)}
disabled={httpSectionDisabled}
siteResourceId={siteResource.id}
resourceDomainId={
siteResource.domainId
}
resourceFullDomain={
siteResource.fullDomain
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-http-form"
loading={saveLoading}
disabled={httpSectionDisabled}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,93 +0,0 @@
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resource"
};
export const dynamic = "force-dynamic";
type PrivateResourceLayoutProps = {
children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>;
};
export default async function PrivateResourceLayout(
props: PrivateResourceLayoutProps
) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
const siteResource = await fetchSiteResourceByNiceId(
params.orgId,
params.niceId
);
if (!siteResource) {
redirect(`/${params.orgId}/settings/resources/private`);
}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources/private`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources/private`);
}
const modeSettingsKey = `${siteResource.mode}Settings` as
| "hostSettings"
| "cidrSettings"
| "httpSettings"
| "sshSettings";
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/resources/private/{niceId}/general`
},
{
title: t(modeSettingsKey),
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
},
{
title: t("authentication"),
href: `/{orgId}/settings/resources/private/{niceId}/access`
}
];
return (
<>
<SettingsSectionTitle
title={t("resourceSetting", {
resourceName: siteResource.name
})}
description={t("resourceSettingDescription")}
/>
<OrgProvider org={org}>
<SiteResourceProvider siteResource={siteResource}>
<div className="space-y-6">
<SiteResourceInfoBox />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</SiteResourceProvider>
</OrgProvider>
</>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resource"
};
export default async function PrivateResourcePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
);
}
@@ -1,229 +0,0 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsFormGrid
} from "@app/components/Settings";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import {
createSshFormSchema,
inferSshPamMode
} from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const isNative = siteResource.authDaemonMode === "native";
const [sshServerMode] = useState<"standard" | "native">(
isNative ? "native" : "standard"
);
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(
() => createSshFormSchema(t, { isNative }),
[t, isNative]
);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "ssh",
destination: siteResource.destination ?? "",
alias: siteResource.alias ?? null,
destinationPort: siteResource.destinationPort ?? null,
pamMode: inferSshPamMode(
siteResource.authDaemonMode,
siteResource.pamMode
),
standardDaemonLocation: isNative
? "site"
: siteResource.authDaemonMode === "remote"
? "remote"
: "site",
authDaemonPort: siteResource.authDaemonPort
? String(siteResource.authDaemonPort)
: "22123"
}
});
const pamMode = form.watch("pamMode");
const standardDaemonLocation = form.watch("standardDaemonLocation");
const authDaemonPort = form.watch("authDaemonPort");
function trimSitesToFirst() {
if (selectedSites.length <= 1) return;
const first = selectedSites.slice(0, 1);
setSelectedSites(first);
form.setValue(
"siteIds",
first.map((s: Selectedsite) => s.siteId),
{ shouldValidate: true }
);
}
function handlePamModeChange(value: "passthrough" | "push") {
form.setValue("pamMode", value, { shouldValidate: true });
if (value === "push") {
if (
standardDaemonLocation !== "remote" &&
selectedSites.length > 1
) {
trimSitesToFirst();
}
return;
}
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
}
function handleDaemonLocationChange(value: "site" | "remote") {
form.setValue("standardDaemonLocation", value, {
shouldValidate: true
});
if (value === "site") {
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
trimSitesToFirst();
}
}
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
const effectiveAuthDaemonMode = isNative
? "native"
: data.standardDaemonLocation;
const effectiveAuthDaemonPort =
!isNative &&
data.pamMode === "push" &&
data.standardDaemonLocation === "remote"
? Number(data.authDaemonPort)
: null;
await save({
siteIds: data.siteIds,
mode: "ssh",
destination: isNative ? null : data.destination,
alias: data.alias,
destinationPort: isNative ? null : data.destinationPort,
authDaemonMode: effectiveAuthDaemonMode,
authDaemonPort: effectiveAuthDaemonPort,
pamMode: data.pamMode
});
}, null);
return (
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SshServerSettingsFields
idPrefix="private-ssh-edit"
pamMode={pamMode}
standardDaemonLocation={
standardDaemonLocation
}
authDaemonPort={authDaemonPort}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={
handleDaemonLocationChange
}
onAuthDaemonPortChange={(value) =>
form.setValue(
"authDaemonPort",
value,
{ shouldValidate: true }
)
}
authDaemonPortError={
form.formState.errors.authDaemonPort
?.message
}
sshServerMode={sshServerMode}
serverModeDisplay="badge"
/>
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
showSshSettings={false}
embedInParentGrid
showPaidFeaturesAlert={false}
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</fieldset>
</SettingsSection>
</SettingsContainer>
);
}
@@ -1,615 +0,0 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import type { Selectedsite } from "@app/components/site-selector";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
buildCreateSiteResourcePayload,
createCreateFormSchema,
type PrivateResourceMode
} from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { SiteResource } from "@server/db";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
import {
PrivateResourceAliasField,
PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields
} from "../PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
export default function CreatePrivateResourcePage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const orgId = params.orgId as string;
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const [isSubmitting, startTransition] = useTransition();
const siteIdParam = searchParams.get("siteId");
const siteIdNumber =
siteIdParam && Number.isInteger(Number(siteIdParam))
? Number(siteIdParam)
: null;
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
siteIds: [],
mode: "host",
destination: "",
alias: null,
destinationPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
authDaemonMode: "native",
standardDaemonLocation: "site",
authDaemonPort: null,
pamMode: "passthrough",
disableIcmp: false
}
});
useEffect(() => {
if (!siteIdNumber) return;
void api
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
.then((res) => {
const site = res.data.data;
if (!site || site.orgId !== orgId) return;
const selected: Selectedsite = {
siteId: site.siteId,
name: site.name,
type: site.type as Selectedsite["type"]
};
setSelectedSites([selected]);
form.setValue("siteIds", [site.siteId]);
})
.catch(() => {});
}, [api, form, orgId, siteIdNumber]);
const mode = form.watch("mode");
const authDaemonMode = form.watch("authDaemonMode");
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
{ value: "host", label: t("createInternalResourceDialogModeHost") },
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
...(!disableEnterpriseFeatures
? [
{
value: "http" as const,
label: t("createInternalResourceDialogModeHttp")
},
{
value: "ssh" as const,
label: t("createInternalResourceDialogModeSsh")
}
]
: [])
];
const submitDisabled =
isSubmitting ||
(mode === "http" && httpSectionDisabled) ||
(mode === "ssh" && sshSectionDisabled);
function onSubmit(values: FormValues) {
startTransition(async () => {
try {
const res = await api.put<
AxiosResponse<ResponseT<SiteResource>>
>(
`/org/${orgId}/site-resource`,
buildCreateSiteResourcePayload({
...values,
destination:
values.destination?.trim() &&
values.destination.trim().length > 0
? values.destination.trim()
: null
})
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
)
});
const created = (res.data as unknown as ResponseT<SiteResource>)
.data;
if (!created) {
throw new Error("Failed to create private resource");
}
router.push(
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
);
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
}
return (
<>
<div className="flex items-start justify-between gap-4">
<HeaderTitle
title={t(
"createInternalResourceDialogCreateClientResource"
)}
description={t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/resources/private`}>
{t("privateResourceCreatePageSeeAll")}
</Link>
</Button>
</div>
<Form {...form}>
<form
id="create-private-resource-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* General */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceCreateGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceCreateGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("type")}
</FormLabel>
<OptionSelect<PrivateResourceMode>
options={modeOptions}
value={field.value}
onChange={(newMode) => {
field.onChange(
newMode
);
if (
newMode ===
"ssh"
) {
form.setValue(
"authDaemonMode",
"native"
);
form.setValue(
"standardDaemonLocation",
"site"
);
form.setValue(
"destination",
null
);
form.setValue(
"destinationPort",
null
);
} else if (
newMode ===
"http"
) {
form.setValue(
"destinationPort",
443
);
} else {
form.setValue(
"destinationPort",
null
);
}
}}
cols={4}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{mode === "http" && (
<SettingsFormCell span="full">
<FormItem>
<DomainPicker
orgId={orgId}
cols={2}
hideFreeDomain
onDomainChange={(res) => {
if (!res) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
</SettingsFormCell>
)}
{(mode === "host" ||
(mode === "ssh" && !isNativeSsh)) && (
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
disabled={
mode === "ssh" &&
sshSectionDisabled
}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Host destination */}
{mode === "host" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceHostDestinationFields
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
hideAlias
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* CIDR destination */}
{mode === "cidr" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("cidrSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationCidrDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceCidrDestinationField
control={asAnyControl(
form.control
)}
labelPrefix="create"
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogHttpConfigurationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={httpSectionDisabled}
className={
httpSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={
selectedSites
}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
orgId={orgId}
watch={asAnyWatch(
form.watch
)}
disabled={
httpSectionDisabled
}
labelPrefix="create"
hideDomainPicker
hidePaidFeaturesAlert
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* SSH server */}
{mode === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
watch={asAnyWatch(form.watch)}
orgId={orgId}
disabled={sshSectionDisabled}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
showPaidFeaturesAlert={false}
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/${orgId}/settings/resources/private`
)
}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="create-private-resource-form"
disabled={submitDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</div>
</form>
</Form>
</>
);
}
@@ -1,24 +0,0 @@
import type {
Control,
FieldValues,
UseFormSetValue,
UseFormWatch
} from "react-hook-form";
export function asAnyControl<T extends FieldValues>(
control: Control<T>
): Control<any> {
return control as Control<any>;
}
export function asAnySetValue<T extends FieldValues>(
setValue: UseFormSetValue<T>
): UseFormSetValue<any> {
return setValue as UseFormSetValue<any>;
}
export function asAnyWatch<T extends FieldValues>(
watch: UseFormWatch<T>
): UseFormWatch<any> {
return watch as UseFormWatch<any>;
}
@@ -122,7 +122,6 @@ export default async function ClientResourcesPage(
aliasAddress: siteResource.aliasAddress || null,
siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId,
enabled: siteResource.enabled,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false,
@@ -1,36 +0,0 @@
"use client";
import type { Selectedsite } from "@app/components/site-selector";
import type { SiteResourceData } from "@app/lib/privateResourceForm";
export function buildSelectedSitesForResource(
resource: Pick<SiteResourceData, "siteIds" | "siteNames">
): Selectedsite[] {
return resource.siteIds.map((siteId, idx) => ({
name: resource.siteNames[idx] ?? "",
siteId,
type: "newt" as const
}));
}
export function getSshSingleSiteMode(
authDaemonMode?: string | null,
pamMode?: string | null
): boolean {
return (
authDaemonMode === "native" ||
(pamMode === "push" && authDaemonMode === "site")
);
}
export function getSshUseMultiSiteTargetForm(
isNative: boolean,
authDaemonMode?: string | null,
pamMode?: string | null
): boolean {
if (isNative) {
return false;
}
return authDaemonMode !== "site" || pamMode === "passthrough";
}
@@ -1,106 +0,0 @@
"use client";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import {
buildUpdateSiteResourcePayload,
mergeFormValuesWithResource,
type PrivateResourceFormValues
} from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export function useSaveSiteResource() {
const t = useTranslations();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { siteResource, updateSiteResource, access } =
useSiteResourceContext();
async function save(
partial: Partial<PrivateResourceFormValues>,
options?: { successMessage?: string }
) {
const merged = mergeFormValuesWithResource(siteResource, partial);
const isNativeSsh =
merged.mode === "ssh" && merged.authDaemonMode === "native";
const trimmedDestination = merged.destination?.trim();
const payload = buildUpdateSiteResourcePayload(
{
...merged,
destination: isNativeSsh
? null
: trimmedDestination && trimmedDestination.length > 0
? trimmedDestination
: null
},
access
);
try {
await api.post(`/site-resource/${siteResource.id}`, payload);
updateSiteResource({
name: merged.name,
niceId: merged.niceId ?? siteResource.niceId,
enabled: merged.enabled ?? siteResource.enabled,
siteIds: merged.siteIds,
mode: merged.mode,
destination: merged.destination ?? null,
alias: merged.alias ?? null,
destinationPort: merged.destinationPort ?? null,
scheme: merged.scheme ?? siteResource.scheme,
ssl: merged.ssl ?? siteResource.ssl,
subdomain: merged.httpConfigSubdomain ?? null,
domainId: merged.httpConfigDomainId ?? null,
fullDomain: merged.httpConfigFullDomain ?? null,
tcpPortRangeString: merged.tcpPortRangeString ?? null,
udpPortRangeString: merged.udpPortRangeString ?? null,
disableIcmp: merged.disableIcmp ?? false,
authDaemonMode: merged.authDaemonMode ?? null,
authDaemonPort: merged.authDaemonPort ?? null,
pamMode: merged.pamMode ?? null
});
toast({
title: t("editInternalResourceDialogSuccess"),
description:
options?.successMessage ??
t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
)
});
if (merged.niceId && merged.niceId !== siteResource.niceId) {
router.replace(
getPrivateResourceSettingsHref(
siteResource.orgId,
merged.niceId
)
);
}
router.refresh();
return true;
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
return false;
}
}
return { save, siteResource, access };
}
@@ -41,7 +41,7 @@ import {
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useParams, useRouter } from "next/navigation";
import { useActionState, useState } from "react";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
});
const [, formAction, saveLoading] = useActionState(onSubmit, null);
const [headersValid, setHeadersValid] = useState(true);
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
if (!headersValid) {
toast({
variant: "destructive",
title: t("settingsErrorUpdate"),
description: t("headersValidationError")
});
return;
}
const data = form.getValues();
const res = await api
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
onChange={
field.onChange
}
onValidityChange={
setHeadersValid
}
rows={4}
/>
</FormControl>
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading || !headersValid}
disabled={saveLoading}
form="http-settings-form"
>
{t("saveSettings")}
@@ -14,13 +14,14 @@ import {
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SitesSelector } from "@app/components/site-selector";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Form,
FormControl,
@@ -34,7 +35,8 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown } from "lucide-react";
import { ChevronsUpDown, ExternalLink } from "lucide-react";
import { Badge } from "@app/components/ui/badge";
import { toast } from "@app/hooks/useToast";
import { useResourceContext } from "@app/hooks/useResourceContext";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -221,7 +223,6 @@ function SshServerForm({
const pamMode = form.watch("pamMode");
const standardDaemonLocation = form.watch("standardDaemonLocation");
const authDaemonPort = form.watch("authDaemonPort");
const selectedNativeSite = form.watch("selectedNativeSite");
async function save() {
@@ -363,6 +364,35 @@ function SshServerForm({
}
}
const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
];
const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
];
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const useMultiSiteTargetForm =
!isNative &&
(standardDaemonLocation !== "site" || pamMode === "passthrough");
@@ -383,37 +413,97 @@ function SshServerForm({
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SshServerSettingsFields
idPrefix="public-ssh-edit"
pamMode={pamMode}
standardDaemonLocation={
standardDaemonLocation
}
authDaemonPort={authDaemonPort}
onPamModeChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
onStandardDaemonLocationChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{ shouldValidate: true }
)
}
onAuthDaemonPortChange={(value) =>
form.setValue("authDaemonPort", value, {
shouldValidate: true
})
}
authDaemonPortError={
form.formState.errors.authDaemonPort
?.message
}
sshServerMode={sshServerMode}
serverModeDisplay="badge"
/>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
<Badge variant="secondary">
{sshServerMode == "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
</div>
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
value={pamMode}
options={authMethodOptions}
onChange={(value) =>
form.setValue("pamMode", value, {
shouldValidate: true
})
}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={(value) =>
form.setValue(
"standardDaemonLocation",
value,
{
shouldValidate: true
}
)
}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<FormField
control={form.control}
name="authDaemonPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshDaemonPort")}
</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={65535}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
@@ -777,8 +777,8 @@ export default function Page() {
<>
<div className="flex justify-between">
<HeaderTitle
title={t("resourcePublicCreate")}
description={t("resourcePublicCreateDescription")}
title={t("resourceCreate")}
description={t("resourceCreateDescription")}
/>
<Button
variant="outline"
+3 -12
View File
@@ -1,4 +1,3 @@
import { cn } from "@app/lib/cn";
import { Check, Copy } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -8,14 +7,12 @@ type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
className?: string;
};
const CopyToClipboard = ({
text,
displayText,
isLink,
className
isLink
}: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
@@ -51,20 +48,14 @@ const CopyToClipboard = ({
href={text}
target="_blank"
rel="noopener noreferrer"
className={cn(
"truncate hover:underline text-sm min-w-0 max-w-full",
className
)}
className="truncate hover:underline text-sm min-w-0 max-w-full"
title={text} // Shows full text on hover
>
{displayValue}
</Link>
) : (
<span
className={cn(
"truncate text-sm min-w-0 max-w-full",
className
)}
className="truncate text-sm min-w-0 max-w-full"
style={{
whiteSpace: "nowrap",
overflow: "hidden",
@@ -0,0 +1,206 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
PrivateResourceForm,
isHostname,
type InternalResourceFormValues
} from "./PrivateResourceForm";
import type { Selectedsite } from "./site-selector";
type CreateInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
orgId: string;
onSuccess?: () => void;
initialSites?: Selectedsite[];
};
export default function CreatePrivateResourceDialog({
open,
setOpen,
orgId,
onSuccess,
initialSites
}: CreateInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
const [isSubmitting, startTransition] = useTransition();
function handleSubmit(values: InternalResourceFormValues) {
startTransition(async () => {
try {
let data = { ...values };
if (
(data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
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 = { ...data, alias: aliasValue };
}
}
await api.put<
AxiosResponse<{ data: { siteResourceId: number } }>
>(`/org/${orgId}/site-resource`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
destination: 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,
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
}),
...(data.mode === "ssh" && {
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) => parseInt(c.id))
: []
});
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
}
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PrivateResourceForm
variant="create"
open={open}
orgId={orgId}
formId="create-internal-resource-form"
onSubmit={handleSubmit}
onSubmitDisabledChange={setIsHttpModeDisabled}
initialSites={initialSites}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="create-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
@@ -0,0 +1,225 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { resourceQueries } from "@app/lib/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState, useTransition } from "react";
import {
cleanForFQDN,
PrivateResourceForm,
type InternalResourceData,
type InternalResourceFormValues,
isHostname
} from "./PrivateResourceForm";
type EditInternalResourceDialogProps = {
open: boolean;
setOpen: (val: boolean) => void;
resource: InternalResourceData;
orgId: string;
onSuccess?: () => void;
};
export default function EditPrivateResourceDialog({
open,
setOpen,
resource,
orgId,
onSuccess
}: EditInternalResourceDialogProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const queryClient = useQueryClient();
const [isSubmitting, startTransition] = useTransition();
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
async function handleSubmit(values: InternalResourceFormValues) {
try {
let data = { ...values };
if (
(data.mode === "host" ||
data.mode === "http" ||
data.mode === "ssh") &&
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 = { ...data, alias: aliasValue };
}
}
await api.post(`/site-resource/${resource.id}`, {
name: data.name,
siteIds: data.siteIds,
mode: data.mode,
niceId: data.niceId,
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,
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
}),
...(data.mode === "ssh" && {
disableIcmp: data.disableIcmp ?? false
}),
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id))
});
await queryClient.invalidateQueries(
resourceQueries.siteResourceRoles({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceUsers({
siteResourceId: resource.id
})
);
await queryClient.invalidateQueries(
resourceQueries.siteResourceClients({
siteResourceId: resource.id
})
);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
),
variant: "default"
});
setOpen(false);
onSuccess?.();
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
}
}
return (
<Credenza
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) setOpen(false);
}}
>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")}
</CredenzaTitle>
<CredenzaDescription>
{t(
"editInternalResourceDialogUpdateResourceProperties",
{
resourceName: resource.name
}
)}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<PrivateResourceForm
variant="edit"
open={open}
resource={resource}
orgId={orgId}
siteResourceId={resource.id}
formId="edit-internal-resource-form"
onSubmit={(values) =>
startTransition(() => handleSubmit(values))
}
onSubmitDisabledChange={setIsHttpModeDisabled}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("editInternalResourceDialogCancel")}
</Button>
</CredenzaClose>
<Button
type="submit"
form="edit-internal-resource-form"
disabled={isSubmitting || isHttpModeDisabled}
loading={isSubmitting}
>
{t("editInternalResourceDialogSaveResource")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+52 -77
View File
@@ -2,36 +2,24 @@
import { useEffect, useState, useRef } from "react";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface HeadersInputProps {
value?: { name: string; value: string }[] | null;
onChange: (value: { name: string; value: string }[] | null) => void;
onValidityChange?: (isValid: boolean) => void;
placeholder?: string;
rows?: number;
className?: string;
}
// Mirrors the server side validation in updateResource.ts so that invalid
// input is caught (and shown to the user) before it is ever submitted,
// instead of being silently dropped in favor of the last known good value.
const validHeaderNamePattern = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/;
const validHeaderValuePattern = /^[\t\x20-\x7E]*$/;
const templatePattern = /\{\{[^}]+\}\}/;
export function HeadersInput({
value = [],
onChange,
onValidityChange,
placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`,
rows = 4,
className
}: HeadersInputProps) {
const t = useTranslations();
const [internalValue, setInternalValue] = useState("");
const [error, setError] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isUserEditingRef = useRef(false);
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
.join("\n");
};
// Parse newline-separated text into header objects, validating each line
// against the same rules enforced by the server. Returns either the
// parsed headers or an error message describing the first invalid line.
const parseHeaders = (
// Convert newline-separated string to header objects array
const convertToHeadersArray = (
newlineSeparated: string
):
| { headers: { name: string; value: string }[]; error: null }
| { headers: null; error: string } => {
if (!newlineSeparated || newlineSeparated.trim() === "") {
return { headers: [], error: null };
}
): { name: string; value: string }[] | null => {
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
const lines = newlineSeparated
return newlineSeparated
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
.filter((line) => line.length > 0 && line.includes(":"))
.map((line) => {
const colonIndex = line.indexOf(":");
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
const headers: { name: string; value: string }[] = [];
// Ensure header name conforms to HTTP header requirements
// Header names should be case-insensitive, contain only ASCII letters, digits, and hyphens
const normalizedName = name
.replace(/[^a-zA-Z0-9\-]/g, "")
.toLowerCase();
for (const line of lines) {
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
return { headers: null, error: t("headersValidationError") };
}
const name = line.substring(0, colonIndex).trim();
const value = line.substring(colonIndex + 1).trim();
if (
!validHeaderNamePattern.test(name) ||
!validHeaderValuePattern.test(value) ||
templatePattern.test(name) ||
templatePattern.test(value)
) {
return { headers: null, error: t("headersValidationError") };
}
headers.push({ name, value });
}
return { headers, error: null };
return { name: normalizedName, value };
})
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
};
// Update internal value when external value changes
// But only if the user is not currently editing (textarea not focused)
useEffect(() => {
if (!isUserEditingRef.current) {
setInternalValue(convertToNewlineSeparated(value ?? []));
setError(null);
onValidityChange?.(true);
setInternalValue(convertToNewlineSeparated(value));
}
}, [value]);
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
// Mark that user is actively editing
isUserEditingRef.current = true;
const result = parseHeaders(newValue);
// Only update parent if the input is in a valid state
// Valid states: empty/whitespace only, or contains properly formatted headers
if (result.error) {
// Surface the error and do not touch the last known good value.
// Silently dropping the update here (without telling the user)
// is what previously let stale data get saved without warning.
setError(result.error);
onValidityChange?.(false);
return;
if (newValue.trim() === "") {
// Empty input is valid - represents no headers
onChange([]);
} else {
// Check if all non-empty lines are properly formatted (contain ':')
const lines = newValue.split("\n");
const nonEmptyLines = lines
.map((line) => line.trim())
.filter((line) => line.length > 0);
// If there are no non-empty lines, or all non-empty lines contain ':', it's valid
const isValid =
nonEmptyLines.length === 0 ||
nonEmptyLines.every((line) => line.includes(":"));
if (isValid) {
// Safe to convert and update parent
const headersArray = convertToHeadersArray(newValue);
onChange(headersArray);
}
// If not valid, don't call onChange - let user continue typing
}
setError(null);
onValidityChange?.(true);
onChange(result.headers);
};
const handleFocus = () => {
@@ -134,20 +114,15 @@ X-Another-Header: another-value`,
};
return (
<div>
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
{error && (
<p className="text-sm text-destructive mt-1.5">{error}</p>
)}
</div>
<Textarea
ref={textareaRef}
value={internalValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
rows={rows}
className={className}
/>
);
}
+3 -11
View File
@@ -5,15 +5,12 @@ import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols,
columnSizing = "content",
layout = "default"
columnSizing = "content"
}: {
children: React.ReactNode;
cols?: number;
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
columnSizing?: "fill" | "content";
/** panel: fixed 2-column grid for narrow containers such as side panels */
layout?: "default" | "panel";
}) {
const n = cols || 1;
const track =
@@ -22,14 +19,9 @@ export function InfoSections({
return (
<div
className={cn(
"grid w-full min-w-0 gap-4",
layout === "panel"
? "grid-cols-2 items-start"
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
columnSizing === "content" &&
(layout === "panel"
? "justify-items-start justify-start"
: "md:justify-items-start md:justify-start")
"md:justify-items-start md:justify-start"
)}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
+3 -17
View File
@@ -11,7 +11,7 @@ import {
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useDebounce } from "use-debounce";
import { Checkbox } from "./ui/checkbox";
@@ -25,7 +25,6 @@ type LabelsFilterSelectorProps = {
orgId: string;
isSelected: (label: LabelFilterOption) => boolean;
onToggle: (label: LabelFilterOption) => void;
selectedLabels?: LabelFilterOption[];
onClear?: () => void;
showClear?: boolean;
scope?: "org" | "launcher";
@@ -35,7 +34,6 @@ export function LabelsFilterSelector({
orgId,
isSelected,
onToggle,
selectedLabels = [],
onClear,
showClear = false,
scope = "org"
@@ -56,7 +54,7 @@ export function LabelsFilterSelector({
...launcherQueries.labels({
orgId,
query: debouncedQuery,
perPage: 20
perPage: 500
}),
enabled: scope === "launcher"
});
@@ -65,18 +63,6 @@ export function LabelsFilterSelector({
? (launcherLabelsQuery.data ?? [])
: (orgLabelsQuery.data ?? []);
const labelsShown = useMemo(() => {
const base = [...labels];
if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) {
const selectedNotInBase = selectedLabels.filter(
(selected) =>
!base.some((label) => label.labelId === selected.labelId)
);
return [...selectedNotInBase, ...base];
}
return base;
}, [debouncedQuery, labels, selectedLabels]);
return (
<Command shouldFilter={false}>
<CommandInput
@@ -95,7 +81,7 @@ export function LabelsFilterSelector({
{t("accessFilterClear")}
</CommandItem>
)}
{labelsShown.map((label) => (
{labels.map((label) => (
<CommandItem
key={label.labelId}
value={label.name}
+49 -69
View File
@@ -175,46 +175,34 @@ export function LayoutSidebar({
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<SquareMousePointer className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>
{t("resourceLauncherTitle")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href={`/${orgId}`}
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("resourceLauncherTitle")
: undefined
}
>
<span
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
!isSidebarCollapsed && "mr-3"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<SquareMousePointer className="h-4 w-4" />
</span>
<SquareMousePointer className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span className="flex-1">
{t("resourceLauncherTitle")}
</span>
</Link>
)}
)}
</Link>
</div>
)}
{!isAdminPage && user.serverAdmin && (
@@ -224,44 +212,36 @@ export function LayoutSidebar({
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
{isSidebarCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-2 py-2 justify-center"
)}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
>
<p>{t("serverAdmin")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Link
href="/admin"
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
!isSidebarCollapsed && "mr-3"
)}
>
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t("serverAdmin")}
</span>
</Link>
)}
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
</>
)}
</Link>
</div>
)}
<SidebarNav
File diff suppressed because it is too large Load Diff
+43 -34
View File
@@ -2,6 +2,8 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
import {
ResourceSitesStatusCell,
@@ -32,14 +34,12 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ArrowUpDown,
ChevronsUpDownIcon,
@@ -47,7 +47,6 @@ import {
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
@@ -79,7 +78,6 @@ export type InternalResourceRow = {
alias: string | null;
aliasAddress: string | null;
niceId: string;
enabled: boolean;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
@@ -142,10 +140,13 @@ export default function PrivateResourcesTable({
const api = createApiClient({ env });
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isNavigatingToAddPage, startNavigation] = useTransition();
const [selectedInternalResource, setSelectedInternalResource] =
useState<InternalResourceRow | null>();
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState<InternalResourceRow | null>();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -449,17 +450,6 @@ export default function PrivateResourcesTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<DropdownMenuItem
onClick={() => {
setSelectedInternalResource(
@@ -474,17 +464,15 @@ export default function PrivateResourcesTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={getPrivateResourceSettingsHref(
resourceRow.orgId,
resourceRow.niceId
)}
<Button
variant={"outline"}
onClick={() => {
setEditingResource(resourceRow);
setIsEditDialogOpen(true);
}}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
{t("edit")}
</Button>
</div>
);
}
@@ -592,15 +580,8 @@ export default function PrivateResourcesTable({
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() =>
startNavigation(() =>
router.push(
`/${orgId}/settings/resources/private/create`
)
)
}
onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")}
isNavigatingToAddPage={isNavigatingToAddPage}
onSearch={handleSearchChange}
onRefresh={refreshData}
onPaginationChange={handlePaginationChange}
@@ -616,6 +597,34 @@ export default function PrivateResourcesTable({
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
{editingResource && (
<EditPrivateResourceDialog
open={isEditDialogOpen}
setOpen={setIsEditDialogOpen}
resource={editingResource}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}}
/>
)}
<CreatePrivateResourceDialog
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}}
/>
</>
);
}
-232
View File
@@ -1,232 +0,0 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails";
import {
formatSiteResourceAccess,
formatSiteResourceDestinationDisplay,
isSafeUrlForLink,
type LauncherAccessFields
} from "@app/lib/launcherResourceAccess";
import type { PrivateResourceMode } from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
type SiteResourceInfoInput = {
mode: PrivateResourceMode;
destination: string | null;
destinationPort: number | null;
scheme: "http" | "https" | null;
ssl: boolean;
fullDomain?: string | null;
alias?: string | null;
aliasAddress?: string | null;
authDaemonMode?: "site" | "remote" | "native" | null;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
};
type SiteResourceInfoBoxVariant = "settings" | "panel";
type SiteResourceInfoSectionsProps = {
siteResource: SiteResourceInfoInput;
access: LauncherAccessFields;
variant: SiteResourceInfoBoxVariant;
accessClassName?: string;
};
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl,
className
}: LauncherAccessFields & { className?: string }) {
if (!accessDisplay) {
return <span>-</span>;
}
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
if (canLink && href) {
return (
<CopyToClipboard
text={href}
displayText={accessDisplay}
isLink
className={className}
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className={className}
/>
);
}
export function SiteResourceInfoSections({
siteResource,
access,
variant,
accessClassName
}: SiteResourceInfoSectionsProps) {
const t = useTranslations();
const isPanel = variant === "panel";
const modeLabel: Record<PrivateResourceMode, string> = {
host: t("editInternalResourceDialogModeHost"),
cidr: t("editInternalResourceDialogModeCidr"),
http: t("editInternalResourceDialogModeHttp"),
ssh: t("editInternalResourceDialogModeSsh")
};
const destination = formatSiteResourceDestinationDisplay({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme
});
const portRestrictions = formatPortRestrictionDisplay({
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*"
});
const showAlias =
siteResource.mode !== "cidr" && siteResource.mode !== "http";
const showDestination = !(
siteResource.mode === "ssh" && siteResource.authDaemonMode === "native"
);
const numSections =
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (isPanel ? 1 : 0);
const sections = (
<InfoSections cols={numSections} layout={isPanel ? "panel" : "default"}>
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{modeLabel[siteResource.mode]}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={access.accessDisplay}
accessCopyValue={access.accessCopyValue}
accessUrl={access.accessUrl}
className={accessClassName}
/>
</InfoSectionContent>
</InfoSection>
{showDestination ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogDestination")}
</InfoSectionTitle>
<InfoSectionContent>
{destination || "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{showAlias ? (
<InfoSection>
<InfoSectionTitle>
{t("editInternalResourceDialogAlias")}
</InfoSectionTitle>
<InfoSectionContent>
{siteResource.alias?.trim() ? siteResource.alias : "-"}
</InfoSectionContent>
</InfoSection>
) : null}
{isPanel ? (
<InfoSection>
<InfoSectionTitle>{t("portRestrictions")}</InfoSectionTitle>
<InfoSectionContent>
{!portRestrictions.hasNonDefaultPorts ? (
<span>
{t("resourceLauncherNoPortRestrictions")}
</span>
) : (
<div className="space-y-1">
{portRestrictions.tcp.state !== "all" ? (
<div>
{t("resourceLauncherTcp")}:{" "}
{portRestrictions.tcp.state ===
"blocked"
? t("blocked")
: portRestrictions.tcp.ports}
</div>
) : null}
{portRestrictions.udp.state !== "all" ? (
<div>
{t("resourceLauncherUdp")}:{" "}
{portRestrictions.udp.state ===
"blocked"
? t("blocked")
: portRestrictions.udp.ports}
</div>
) : null}
</div>
)}
</InfoSectionContent>
</InfoSection>
) : null}
</InfoSections>
);
if (isPanel) {
return sections;
}
return (
<Alert>
<AlertDescription>{sections}</AlertDescription>
</Alert>
);
}
type SiteResourceInfoBoxProps = {
variant?: "settings";
};
export default function SiteResourceInfoBox({
variant = "settings"
}: SiteResourceInfoBoxProps) {
const { siteResource } = useSiteResourceContext();
const access = formatSiteResourceAccess({
mode: siteResource.mode,
destination: siteResource.destination,
destinationPort: siteResource.destinationPort,
scheme: siteResource.scheme,
ssl: siteResource.ssl,
fullDomain: siteResource.fullDomain ?? null,
alias: siteResource.alias ?? null,
aliasAddress: siteResource.aliasAddress ?? null
});
return (
<SiteResourceInfoSections
siteResource={siteResource}
access={access}
variant={variant}
/>
);
}
+13 -8
View File
@@ -7,7 +7,6 @@ import { SettingsContainer } from "@app/components/Settings";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api";
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { ListResourcesResponse } from "@server/routers/resource";
import type ResponseT from "@server/types/Response";
@@ -433,13 +432,19 @@ export default function SiteResourcesOverview({
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
}));
const privateRows = privateList.map((row) => ({
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
}));
const privateRows = privateList.map((row) => {
const qs = new URLSearchParams({
siteId: String(siteId),
query: row.niceId
});
return {
key: row.siteResourceId,
meta: <PrivateResourceMeta row={row} />,
name: row.name,
access: <PrivateAccessMethod row={row} />,
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
};
});
if (showEmptyPlaceholder) {
return (
-201
View File
@@ -1,201 +0,0 @@
"use client";
import { SettingsFormCell } from "@app/components/Settings";
import {
StrategySelect,
type StrategyOption
} from "@app/components/StrategySelect";
import { Badge } from "@app/components/ui/badge";
import { Input } from "@app/components/ui/input";
import { Label } from "@app/components/ui/label";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo } from "react";
export type SshServerSettingsFormFields = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
};
type SshServerSettingsFieldsProps = {
pamMode: "passthrough" | "push";
standardDaemonLocation: "site" | "remote";
authDaemonPort: string;
onPamModeChange: (value: "passthrough" | "push") => void;
onStandardDaemonLocationChange: (value: "site" | "remote") => void;
onAuthDaemonPortChange: (value: string) => void;
authDaemonPortError?: string;
sshServerMode: "standard" | "native";
serverModeDisplay: "badge" | "select";
onServerModeChange?: (mode: "standard" | "native") => void;
sshServerModeOptions?: StrategyOption<"standard" | "native">[];
idPrefix?: string;
};
export function SshServerSettingsFields({
pamMode,
standardDaemonLocation,
authDaemonPort,
onPamModeChange,
onStandardDaemonLocationChange,
onAuthDaemonPortChange,
authDaemonPortError,
sshServerMode,
serverModeDisplay,
onServerModeChange,
sshServerModeOptions,
idPrefix = "ssh-server"
}: SshServerSettingsFieldsProps) {
const t = useTranslations();
const isNative = sshServerMode === "native";
const showDaemonLocation = !isNative && pamMode === "push";
const showDaemonPort =
!isNative && pamMode === "push" && standardDaemonLocation === "remote";
const authMethodOptions = useMemo(
(): StrategyOption<"passthrough" | "push">[] => [
{
id: "passthrough",
title: t("sshAuthMethodManual"),
description: t("sshAuthMethodManualDescription")
},
{
id: "push",
title: t("sshAuthMethodAutomated"),
description: t("sshAuthMethodAutomatedDescription")
}
],
[t]
);
const daemonLocationOptions = useMemo(
(): StrategyOption<"site" | "remote">[] => [
{
id: "site",
title: t("internalResourceAuthDaemonSite"),
description: t("sshDaemonLocationSiteDescription")
},
{
id: "remote",
title: t("sshDaemonLocationRemote"),
description: t("sshDaemonLocationRemoteDescription")
}
],
[t]
);
const defaultSshServerModeOptions = useMemo(
(): StrategyOption<"standard" | "native">[] => [
{
id: "native",
title: t("sshServerModePangolin"),
description: t("sshServerModeNativeDescription")
},
{
id: "standard",
title: t("sshServerModeStandard"),
description: t("sshServerModeStandardDescription")
}
],
[t]
);
const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions;
return (
<>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshServerMode")}
</p>
{serverModeDisplay === "badge" ? (
<Badge variant="secondary">
{sshServerMode === "standard"
? t("sshServerModeStandard")
: t("sshServerModePangolin")}
</Badge>
) : (
<StrategySelect<"standard" | "native">
idPrefix={`${idPrefix}-mode`}
value={sshServerMode}
options={modeOptions}
onChange={(value) => onServerModeChange?.(value)}
cols={2}
/>
)}
</div>
</SettingsFormCell>
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthenticationMethod")}
</p>
<StrategySelect<"passthrough" | "push">
idPrefix={`${idPrefix}-auth`}
value={pamMode}
options={authMethodOptions}
onChange={onPamModeChange}
cols={2}
/>
</div>
</SettingsFormCell>
{showDaemonLocation && (
<SettingsFormCell span="full">
<div className="space-y-2">
<p className="font-semibold text-sm">
{t("sshAuthDaemonLocation")}
</p>
<StrategySelect<"site" | "remote">
idPrefix={`${idPrefix}-daemon`}
value={standardDaemonLocation}
options={daemonLocationOptions}
onChange={onStandardDaemonLocationChange}
cols={2}
/>
<p className="text-sm text-muted-foreground">
{t("sshDaemonDisclaimer")}{" "}
<a
href="https://docs.pangolin.net/manage/ssh"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("learnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</p>
</div>
</SettingsFormCell>
)}
{showDaemonPort && (
<SettingsFormCell span="half">
<div className="grid gap-2">
<Label htmlFor={`${idPrefix}-daemon-port`}>
{t("sshDaemonPort")}
</Label>
<Input
id={`${idPrefix}-daemon-port`}
type="number"
min={1}
max={65535}
value={authDaemonPort}
onChange={(e) =>
onAuthDaemonPortChange(e.target.value)
}
/>
{authDaemonPortError ? (
<p className="text-destructive text-sm">
{authDaemonPortError}
</p>
) : null}
</div>
</SettingsFormCell>
)}
</>
);
}
+35 -43
View File
@@ -18,7 +18,6 @@ interface StrategySelectProps<TValue extends string> {
defaultValue?: TValue;
onChange?: (value: TValue) => void;
cols?: number;
idPrefix?: string;
}
export function StrategySelect<TValue extends string>({
@@ -26,8 +25,7 @@ export function StrategySelect<TValue extends string>({
value: controlledValue,
defaultValue,
onChange,
cols = 1,
idPrefix = "strategy"
cols = 1
}: StrategySelectProps<TValue>) {
const [uncontrolledSelected, setUncontrolledSelected] = useState<
TValue | undefined
@@ -51,49 +49,43 @@ export function StrategySelect<TValue extends string>({
}}
className="grid md:grid-cols-(--cols) gap-4"
>
{options.map((option: StrategyOption<TValue>) => {
const optionId = `${idPrefix}-${option.id}`;
return (
<label
key={option.id}
htmlFor={optionId}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
{options.map((option: StrategyOption<TValue>) => (
<label
key={option.id}
htmlFor={option.id}
data-state={
selected === option.id ? "checked" : "unchecked"
}
className={cn(
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
option.disabled
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
: selected === option.id
? "border-primary bg-primary/10 text-primary"
: "border-input hover:bg-accent"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
)}
>
<RadioGroupItem
value={option.id}
id={optionId}
disabled={option.disabled}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="flex gap-3 pl-7">
{option.icon && (
<div className="mt-1">{option.icon}</div>
)}
<div className="flex-1">
<div className="font-medium">
{option.title}
</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
</div>
<div className="flex-1">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{typeof option.description === "string"
? option.description
: option.description}
</div>
</div>
</label>
);
})}
</div>
</label>
))}
</RadioGroup>
);
}
+2 -14
View File
@@ -20,8 +20,6 @@ export type MultiSitesSelectorProps = {
onSelectionChange: (sites: Selectedsite[]) => void;
filterTypes?: string[];
scope?: "org" | "launcher";
onClear?: () => void;
showClear?: boolean;
};
export function formatMultiSitesSelectorLabel(
@@ -44,9 +42,7 @@ export function MultiSitesSelector({
selectedSites,
onSelectionChange,
filterTypes,
scope = "org",
onClear,
showClear = false
scope = "org"
}: MultiSitesSelectorProps) {
const t = useTranslations();
const [siteSearchQuery, setSiteSearchQuery] = useState("");
@@ -64,7 +60,7 @@ export function MultiSitesSelector({
...launcherQueries.sites({
orgId,
query: debouncedQuery,
perPage: 20
perPage: 500
}),
enabled: scope === "launcher"
});
@@ -111,14 +107,6 @@ export function MultiSitesSelector({
<CommandList>
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{sitesShown.map((site) => (
<CommandItem
key={site.siteId}
@@ -10,7 +10,6 @@ import {
type SelectedLabel
} from "@app/components/labels-selector";
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
Popover,
@@ -47,14 +46,14 @@ export function LauncherFilterPopover({
const { data: labels = [] } = useQuery(
launcherQueries.labels({
orgId,
perPage: 20
perPage: 500
})
);
const { data: sites = [] } = useQuery(
launcherQueries.sites({
orgId,
perPage: 20
perPage: 500
})
);
@@ -97,32 +96,14 @@ export function LauncherFilterPopover({
[labels, selectedLabels]
);
const activeFilterCount = selectedSites.length + selectedLabels.length;
return (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className="relative shrink-0"
>
<Button variant="outline" size="icon" className="shrink-0">
<Funnel className="size-4" />
<span className="sr-only">
{activeFilterCount > 0
? t("resourceLauncherFilterWithCount", {
count: activeFilterCount
})
: t("resourceLauncherFilter")}
{t("resourceLauncherFilter")}
</span>
{activeFilterCount > 0 && (
<Badge
variant="secondary"
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1.5 text-xs"
>
{activeFilterCount > 99 ? "99+" : activeFilterCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
@@ -158,10 +139,6 @@ export function LauncherFilterPopover({
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
showClear={selectedSites.length > 0}
onClear={() => {
onSitesChange([]);
}}
/>
</PopoverContent>
</Popover>
@@ -195,7 +172,6 @@ export function LauncherFilterPopover({
<LabelsFilterSelector
orgId={orgId}
scope="launcher"
selectedLabels={resolvedSelectedLabels}
isSelected={(label) =>
selectedLabelIds.has(label.labelId)
}
@@ -1,125 +0,0 @@
"use client";
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import { hasActiveLauncherFilters } from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import type {
LauncherResource,
LauncherViewConfig
} from "@server/routers/launcher/types";
import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types";
import { useInfiniteQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useEffect, useMemo, useRef } from "react";
import { LauncherEmptyState } from "./LauncherEmptyState";
import { LauncherResourceGrid } from "./LauncherResourceGrid";
import { LauncherResourceList } from "./LauncherResourceList";
type LauncherFlatResourceListProps = {
orgId: string;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
onClearFilters?: () => void;
onResourceSelect?: (resource: LauncherResource) => void;
};
export function LauncherFlatResourceList({
orgId,
config,
onClearFilters,
onResourceSelect
}: LauncherFlatResourceListProps) {
const loadMoreRef = useRef<HTMLDivElement | null>(null);
const resourceFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
groupKey: LAUNCHER_FLAT_GROUP_KEY,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order,
pageSize: 20
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.resources(orgId, resourceFilters)
});
const resources = data?.pages.flatMap((page) => page.resources) ?? [];
useEffect(() => {
const node = loadMoreRef.current;
if (!node || !hasNextPage) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "200px" }
);
observer.observe(node);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (resources.length === 0) {
if (isFetching) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<LauncherEmptyState
variant={
hasActiveLauncherFilters(config) ? "noResults" : "empty"
}
layout={config.layout}
query={config.query}
onClearFilters={onClearFilters}
/>
);
}
return (
<div className="flex flex-col gap-2.5">
{config.layout === "grid" ? (
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
);
}
@@ -69,20 +69,16 @@ export function LauncherGroupList({
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
useInfiniteQuery({
...launcherQueries.groups(orgId, groupFilters),
...(initialGroups.length > 0
? {
initialData: {
pages: [
{
groups: initialGroups,
pagination: groupsPagination
}
],
pageParams: [1]
},
refetchOnMount: false as const
}
: {})
initialData: {
pages: [
{
groups: initialGroups,
pagination: groupsPagination
}
],
pageParams: [1]
},
refetchOnMount: false
});
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
@@ -23,8 +23,9 @@ export function LauncherRefreshButton({
className="shrink-0"
>
<RefreshCw
className={`size-4 ${isRefreshing ? "animate-spin" : ""}`}
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">{t("refresh")}</span>
</Button>
);
}
@@ -1,52 +1,17 @@
"use client";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { SiteResourceInfoSections } from "@app/components/SiteResourceInfoBox";
import {
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
SidePanel,
SidePanelBody,
SidePanelContent,
SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import {
derivePublicAuthState,
formatPublicResourceType
} from "@app/lib/launcherResourceDetails";
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Clock,
ExternalLink,
Loader2,
ShieldCheck,
ShieldOff,
XCircle
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
@@ -58,368 +23,6 @@ type LauncherResourcePanelProps = {
isAdmin: boolean;
};
type LauncherResourceDetailResult =
| {
resourceType: "public";
data: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}
| { resourceType: "site"; data: GetSiteResourceResponse };
function AccessMethodContent({
accessDisplay,
accessCopyValue,
accessUrl
}: {
accessDisplay: string;
accessCopyValue: string;
accessUrl?: string | null;
}) {
if (!accessDisplay) {
return <span>-</span>;
}
const href = accessUrl ?? undefined;
const canLink = Boolean(href && isSafeUrlForLink(href));
if (canLink && href) {
return (
<CopyToClipboard
text={href}
displayText={accessDisplay}
isLink={true}
className="text-base"
/>
);
}
return (
<CopyToClipboard
text={accessCopyValue || accessDisplay}
displayText={accessDisplay}
isLink={false}
className="text-base"
/>
);
}
function HealthStatusDisplay({
health
}: {
health: string | null | undefined;
}) {
const t = useTranslations();
const status = health ?? "unknown";
if (status === "healthy") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-green-500" />
<span>{t("resourcesTableHealthy")}</span>
</div>
);
}
if (status === "degraded") {
return (
<div className="flex items-center space-x-2">
<CheckCircle2 className="size-4 shrink-0 text-yellow-500" />
<span>{t("resourcesTableDegraded")}</span>
</div>
);
}
if (status === "unhealthy") {
return (
<div className="flex items-center space-x-2">
<XCircle className="size-4 shrink-0 text-destructive" />
<span>{t("resourcesTableUnhealthy")}</span>
</div>
);
}
return (
<div className="flex items-center space-x-2">
<Clock className="size-4 shrink-0 text-muted-foreground" />
<span>{t("resourcesTableUnknown")}</span>
</div>
);
}
const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"];
function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) {
const t = useTranslations();
return (
<div className="flex items-center gap-2">
{enabled ? (
<CheckCircle2 className="size-4 text-green-600" />
) : (
<XCircle className="size-4 text-red-600" />
)}
<span>{enabled ? t("enabled") : t("disabled")}</span>
</div>
);
}
function PublicResourceAuthMethods({
authInfo
}: {
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const authMethods = [
{
key: "sso",
title: t("policyAuthSsoTitle"),
enabled: authInfo.sso
},
{
key: "password",
title: t("policyAuthPasscodeTitle"),
enabled: authInfo.password
},
{
key: "pincode",
title: t("policyAuthPincodeTitle"),
enabled: authInfo.pincode
},
{
key: "whitelist",
title: t("policyAuthEmailTitle"),
enabled: authInfo.whitelist
},
{
key: "headerAuth",
title: t("policyAuthHeaderAuthTitle"),
enabled: authInfo.headerAuth
}
];
return (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authentication")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherAuthMethodsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={authMethods.length} layout="panel">
{authMethods.map((method) => (
<InfoSection key={method.key}>
<InfoSectionTitle>{method.title}</InfoSectionTitle>
<InfoSectionContent>
<AuthMethodStatusDisplay
enabled={method.enabled}
/>
</InfoSectionContent>
</InfoSection>
))}
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
);
}
function PublicResourceDetails({
launcherResource,
resource,
authInfo
}: {
launcherResource: LauncherResource;
resource: GetResourceResponse;
authInfo: GetResourceAuthInfoResponse;
}) {
const t = useTranslations();
const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes(
resource.mode || ""
);
const authState = derivePublicAuthState(resource.mode, authInfo);
const infoSectionCount = supportsAuth ? 4 : 3;
return (
<div className="space-y-4">
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={infoSectionCount} layout="panel">
<InfoSection>
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
<InfoSectionContent>
{formatPublicResourceType(resource)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
<InfoSectionContent>
<AccessMethodContent
accessDisplay={
launcherResource.accessDisplay
}
accessCopyValue={
launcherResource.accessCopyValue
}
accessUrl={launcherResource.accessUrl}
/>
</InfoSectionContent>
</InfoSection>
{supportsAuth ? (
<InfoSection>
<InfoSectionTitle>
{t("authentication")}
</InfoSectionTitle>
<InfoSectionContent>
{authState === "protected" ? (
<div className="flex items-center space-x-2">
<ShieldCheck className="size-4 shrink-0 text-green-500" />
<span>{t("protected")}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<ShieldOff className="size-4 shrink-0 text-yellow-500" />
<span>{t("notProtected")}</span>
</div>
)}
</InfoSectionContent>
</InfoSection>
) : null}
<InfoSection>
<InfoSectionTitle>{t("health")}</InfoSectionTitle>
<InfoSectionContent>
<HealthStatusDisplay health={resource.health} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
</SettingsSectionBody>
</SettingsSection>
{supportsAuth ? (
<PublicResourceAuthMethods authInfo={authInfo} />
) : null}
</div>
);
}
function PrivateResourceDetails({
launcherResource,
resource
}: {
launcherResource: LauncherResource;
resource: GetSiteResourceResponse;
}) {
const t = useTranslations();
return (
<div className="space-y-4">
<Alert variant="default">
<AlertCircle className="size-4" />
<AlertTitle>
{t("resourceLauncherPrivateClientRequiredTitle")}
</AlertTitle>
<AlertDescription>
<span>
{t("resourceLauncherPrivateClientRequired")}{" "}
<a
href="https://pangolin.net/downloads"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
{t("resourceLauncherDownloadClient")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
</span>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceLauncherResourceDetails")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SiteResourceInfoSections
siteResource={resource}
access={{
accessDisplay: launcherResource.accessDisplay,
accessCopyValue: launcherResource.accessCopyValue,
accessUrl: launcherResource.accessUrl
}}
variant="panel"
accessClassName="text-base"
/>
</SettingsSectionBody>
</SettingsSection>
</div>
);
}
function LauncherResourcePanelBody({
orgId,
resource,
open
}: {
orgId: string;
resource: LauncherResource;
open: boolean;
}) {
const t = useTranslations();
const { data, isPending, isError } = useQuery({
...launcherQueries.resourceDetail(orgId, resource),
enabled: open
});
if (isPending) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
if (isError || !data) {
return (
<p className="text-sm text-muted-foreground">
{t("resourceLauncherFailedToLoadDetails")}
</p>
);
}
const detail = data as LauncherResourceDetailResult;
if (detail.resourceType === "public") {
return (
<PublicResourceDetails
launcherResource={resource}
resource={detail.data}
authInfo={detail.authInfo}
/>
);
}
return (
<PrivateResourceDetails
launcherResource={resource}
resource={detail.data}
/>
);
}
export function LauncherResourcePanel({
open,
onOpenChange,
@@ -434,16 +37,11 @@ export function LauncherResourcePanel({
<SidePanelContent>
<SidePanelHeader>
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
<SidePanelDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SidePanelDescription>
</SidePanelHeader>
<SidePanelBody>
{resource ? (
<LauncherResourcePanelBody
orgId={orgId}
resource={resource}
open={open}
/>
) : null}
</SidePanelBody>
<SidePanelBody />
<SidePanelFooter>
<Button
variant="outline"
@@ -1,86 +0,0 @@
"use client";
import { cn } from "@app/lib/cn";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
type LauncherSearchFirstGateProps = {
layout: "grid" | "list";
};
function GhostResourceGrid() {
return (
<div
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
aria-hidden
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
>
<div className="flex items-center gap-5">
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
<div className="h-3 w-2/5 rounded bg-muted/40" />
</div>
</div>
</div>
))}
</div>
);
}
function GhostResourceList() {
return (
<div className="flex w-full flex-col" aria-hidden>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
"flex items-center gap-4 px-4 py-3",
index < 2 && "border-b border-border/60"
)}
>
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
<div className="h-3 w-1/4 rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
}
export function LauncherSearchFirstGate({
layout
}: LauncherSearchFirstGateProps) {
const t = useTranslations();
return (
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
<div className="pointer-events-none absolute inset-0 opacity-50">
{layout === "grid" ? (
<GhostResourceGrid />
) : (
<GhostResourceList />
)}
</div>
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Search className="size-5 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
{t("resourceLauncherSearchFirstTitle")}
</h3>
<p className="text-sm text-muted-foreground">
{t("resourceLauncherSearchFirstDescription")}
</p>
</div>
</div>
</div>
);
}
@@ -15,27 +15,22 @@ import {
SelectValue
} from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import type {
LauncherScaleCapabilities,
LauncherViewConfig
} from "@server/routers/launcher/types";
import type { LauncherViewConfig } from "@server/routers/launcher/types";
import { useTranslations } from "next-intl";
import { Settings } from "lucide-react";
type LauncherSettingsMenuProps = {
config: LauncherViewConfig;
capabilities: LauncherScaleCapabilities;
isCompactMode: boolean;
selectedGroupBy: LauncherViewConfig["groupBy"];
isDefaultView: boolean;
onConfigChange: (patch: Partial<LauncherViewConfig>) => void;
onDeleteView: () => void;
};
export function LauncherSettingsMenu({
config,
capabilities,
isCompactMode,
selectedGroupBy,
onConfigChange
isDefaultView,
onConfigChange,
onDeleteView
}: LauncherSettingsMenuProps) {
const t = useTranslations();
@@ -56,7 +51,7 @@ export function LauncherSettingsMenu({
{t("resourceLauncherGroupBy")}
</p>
<Select
value={selectedGroupBy}
value={config.groupBy}
onValueChange={(value) =>
onConfigChange({
groupBy:
@@ -68,46 +63,14 @@ export function LauncherSettingsMenu({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{t("resourceLauncherGroupByNone")}
</SelectItem>
<SelectItem
value="site"
disabled={
!capabilities.allowSiteGrouping ||
(isCompactMode &&
config.siteIds.length === 0)
}
>
<SelectItem value="site">
{t("resourceLauncherGroupBySite")}
</SelectItem>
<SelectItem
value="label"
disabled={
!capabilities.allowLabelGrouping ||
(isCompactMode &&
config.labelIds.length === 0)
}
>
<SelectItem value="label">
{t("resourceLauncherGroupByLabel")}
</SelectItem>
</SelectContent>
</Select>
{isCompactMode ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherCompactGroupingHint")}
</p>
) : null}
{!isCompactMode && !capabilities.allowSiteGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherSiteGroupingDisabled")}
</p>
) : null}
{!isCompactMode && !capabilities.allowLabelGrouping ? (
<p className="text-xs text-muted-foreground">
{t("resourceLauncherLabelGroupingDisabled")}
</p>
) : null}
</div>
<div className="space-y-2">
@@ -153,6 +116,16 @@ export function LauncherSettingsMenu({
/>
</div>
</div>
{!isDefaultView ? (
<Button
variant="destructive"
className="w-full rounded-xl"
onClick={onDeleteView}
>
{t("resourceLauncherDeleteView")}
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
@@ -68,14 +68,11 @@ type LauncherSaveViewMenuProps = {
isAdmin: boolean;
isOrgWideView: boolean;
hasUnsavedChanges: boolean;
canResetSystemDefault: boolean;
onSaveToCurrent: () => void;
onSaveAsNew: () => void;
onSaveForEveryone: () => void;
onMakePersonal: () => void;
onResetView: () => void;
onResetSystemDefault: () => void;
onDeleteView: () => void;
};
export function LauncherSaveViewMenu({
@@ -83,17 +80,13 @@ export function LauncherSaveViewMenu({
isAdmin,
isOrgWideView,
hasUnsavedChanges,
canResetSystemDefault,
onSaveToCurrent,
onSaveAsNew,
onSaveForEveryone,
onMakePersonal,
onResetView,
onResetSystemDefault,
onDeleteView
onResetView
}: LauncherSaveViewMenuProps) {
const t = useTranslations();
const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView);
return (
<DropdownMenu>
@@ -115,26 +108,7 @@ export function LauncherSaveViewMenu({
<DropdownMenuSeparator />
</>
) : null}
{isDefaultView && canResetSystemDefault ? (
<>
<DropdownMenuItem onSelect={onResetSystemDefault}>
{t("resourceLauncherResetSystemDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{isDefaultView && hasUnsavedChanges ? (
<>
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveDefaultPersonal")}
</DropdownMenuItem>
{isAdmin ? (
<DropdownMenuItem onSelect={onSaveForEveryone}>
{t("resourceLauncherSaveForEveryone")}
</DropdownMenuItem>
) : null}
</>
) : !isDefaultView && (isAdmin || !isOrgWideView) ? (
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveToCurrentView")}
</DropdownMenuItem>
@@ -152,17 +126,6 @@ export function LauncherSaveViewMenu({
{t("resourceLauncherMakePersonal")}
</DropdownMenuItem>
) : null}
{canDeleteView ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={onDeleteView}
className="text-destructive focus:text-destructive"
>
{t("resourceLauncherDeleteView")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -29,23 +29,12 @@ import {
} from "@app/lib/launcherUrlState";
import { useToast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
getEffectiveLauncherConfig,
shouldShowFlatResourceList,
shouldShowLauncherGroupList,
shouldShowSearchFirstGate
} from "@app/lib/launcherScale";
import { launcherQueries } from "@app/lib/queries";
import {
getEffectiveDefaultLauncherConfig,
type LauncherDefaultViewOverrides,
type LauncherGroup,
type LauncherResource,
type LauncherScaleInfo,
type LauncherViewConfig,
type LauncherViewRecord
import type {
LauncherGroup,
LauncherViewConfig,
LauncherViewRecord
} from "@server/routers/launcher/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
@@ -63,26 +52,20 @@ import type { SelectedLabel } from "@app/components/labels-selector";
import { useMediaQuery } from "@app/hooks/useMediaQuery";
import { cn } from "@app/lib/cn";
import { LauncherFilterPopover } from "./LauncherFilterPopover";
import { LauncherFlatResourceList } from "./LauncherFlatResourceList";
import { LauncherGroupList } from "./LauncherGroupList";
import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate";
import { LauncherRefreshButton } from "./LauncherRefreshButton";
import { LauncherResourcePanel } from "./LauncherResourcePanel";
import { LauncherSettingsMenu } from "./LauncherSettingsMenu";
import { LauncherSortButton } from "./LauncherSortButton";
import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
type ResourceLauncherProps = {
orgId: string;
isAdmin: boolean;
views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
activeViewId: LauncherActiveViewId;
config: LauncherViewConfig;
savedConfig: LauncherViewConfig;
scale: LauncherScaleInfo;
groups: LauncherGroup[];
groupsPagination: {
total: number;
@@ -95,11 +78,9 @@ export default function ResourceLauncher({
orgId,
isAdmin,
views,
defaultViewOverrides,
activeViewId,
config,
savedConfig,
scale: initialScale,
groups,
groupsPagination
}: ResourceLauncherProps) {
@@ -107,7 +88,6 @@ export default function ResourceLauncher({
const { toast } = useToast();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const router = useRouter();
const { navigate, isNavigating, searchParams } = useNavigationContext();
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -115,47 +95,11 @@ export default function ResourceLauncher({
const [searchInputResetKey, setSearchInputResetKey] = useState(0);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newViewName, setNewViewName] = useState("");
const [saveOrgWide, setSaveOrgWide] = useState(false);
const [selectedResource, setSelectedResource] =
useState<LauncherResource | null>(null);
const [panelOpen, setPanelOpen] = useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
const scaleFilters = useMemo(
() => ({
query: config.query,
groupBy: config.groupBy,
siteIds: config.siteIds,
labelIds: config.labelIds,
sort_by: config.sortBy,
order: config.order
}),
[
config.groupBy,
config.labelIds,
config.order,
config.query,
config.siteIds,
config.sortBy
]
);
const { data: scale = initialScale } = useQuery({
...launcherQueries.scale(orgId, scaleFilters),
initialData: initialScale
});
const showGroupList = shouldShowLauncherGroupList(scale, config);
const showSearchFirstGate = shouldShowSearchFirstGate(scale, config);
const showFlatResourceList = shouldShowFlatResourceList(scale, config);
const effectiveConfig = useMemo(
() => getEffectiveLauncherConfig(scale, config),
[config, scale]
);
const configRef = useRef(config);
configRef.current = config;
const searchInputRef = useRef(config.query);
@@ -185,24 +129,13 @@ export default function ResourceLauncher({
return;
}
const baseConfig = getLauncherUrlBaseConfig(
lastView,
views,
defaultViewOverrides
);
const baseConfig = getLauncherUrlBaseConfig(lastView, views);
const params = serializeLauncherUrlState({
viewId: lastView,
config: baseConfig
});
navigate({ searchParams: params, replace: true });
}, [
activeViewId,
defaultViewOverrides,
navigate,
orgId,
searchParams,
views
]);
}, [activeViewId, navigate, orgId, searchParams, views]);
const navigateToConfig = useCallback(
(viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => {
@@ -225,14 +158,10 @@ export default function ResourceLauncher({
const selectView = useCallback(
(viewId: LauncherActiveViewId) => {
writeLauncherLastView(orgId, viewId);
const baseConfig = getLauncherUrlBaseConfig(
viewId,
views,
defaultViewOverrides
);
const baseConfig = getLauncherUrlBaseConfig(viewId, views);
navigateToConfig(viewId, baseConfig);
},
[defaultViewOverrides, navigateToConfig, orgId, views]
[navigateToConfig, orgId, views]
);
const activeSavedView = useMemo(
@@ -246,10 +175,6 @@ export default function ResourceLauncher({
const isDefaultView = activeViewId === "default";
const isOrgWideView = Boolean(activeSavedView?.isOrgWide);
const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig);
const canResetSystemDefault =
isDefaultView &&
(Boolean(defaultViewOverrides.personal) ||
(isAdmin && Boolean(defaultViewOverrides.orgWide)));
const selectedSites: Selectedsite[] = useMemo(
() =>
@@ -345,87 +270,6 @@ export default function ResourceLauncher({
}
});
const saveDefaultViewMutation = useMutation({
mutationFn: async (payload: {
config: LauncherViewConfig;
orgWide: boolean;
}) => {
const res = await api.put(
`/org/${orgId}/launcher/default-view`,
payload
);
return res.data.data as LauncherViewRecord;
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
config
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherViewSaved"),
description: t("resourceLauncherViewSavedDescription")
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const resetSystemDefaultMutation = useMutation({
mutationFn: async () => {
const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide);
await api.delete(`/org/${orgId}/launcher/default-view`, {
data: resetAll ? { all: true } : { orgWide: false }
});
},
onSuccess: () => {
writeLauncherLastView(orgId, "default");
const nextOverrides: LauncherDefaultViewOverrides = {
personal: null,
orgWide:
isAdmin && defaultViewOverrides.orgWide
? null
: defaultViewOverrides.orgWide
};
const targetConfig =
getEffectiveDefaultLauncherConfig(nextOverrides);
searchInputRef.current = targetConfig.query;
setSearchInputResetKey((key) => key + 1);
const params = serializeLauncherUrlState({
viewId: "default",
config: targetConfig
});
navigate({ searchParams: params, replace: true });
router.refresh();
toast({
title: t("resourceLauncherSystemDefaultRestored"),
description: t(
"resourceLauncherSystemDefaultRestoredDescription"
)
});
},
onError: (error) => {
toast({
variant: "destructive",
title: t("resourceLauncherViewSaveFailed"),
description: formatAxiosError(
error,
t("resourceLauncherViewSaveFailedDescription")
)
});
}
});
const deleteViewMutation = useMutation({
mutationFn: async (viewId: number) => {
await api.delete(`/org/${orgId}/launcher/views/${viewId}`);
@@ -434,11 +278,7 @@ export default function ResourceLauncher({
writeLauncherLastView(orgId, "default");
const params = serializeLauncherUrlState({
viewId: "default",
config: getLauncherUrlBaseConfig(
"default",
views,
defaultViewOverrides
)
config: getLauncherUrlBaseConfig("default", views)
});
navigate({ searchParams: params, replace: true });
router.refresh();
@@ -488,17 +328,9 @@ export default function ResourceLauncher({
navigateToConfig(activeViewIdRef.current, savedConfig);
}, [navigateToConfig, savedConfig]);
const handleResetSystemDefault = useCallback(() => {
resetSystemDefaultMutation.mutate();
}, [resetSystemDefaultMutation]);
const refreshData = () => {
startRefreshTransition(async () => {
try {
await api.post(`/org/${orgId}/launcher/invalidate-cache`);
await queryClient.invalidateQueries({
queryKey: ["ORG", orgId, "LAUNCHER"]
});
router.refresh();
} catch {
toast({
@@ -511,14 +343,7 @@ export default function ResourceLauncher({
};
const handleSaveToCurrent = () => {
if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: false
});
return;
}
if (isOrgWideView && !isAdmin) {
if (isDefaultView || (isOrgWideView && !isAdmin)) {
return;
}
updateViewMutation.mutate({
@@ -535,10 +360,6 @@ export default function ResourceLauncher({
const handleSaveForEveryone = () => {
if (isDefaultView) {
saveDefaultViewMutation.mutate({
config,
orgWide: true
});
return;
}
updateViewMutation.mutate({
@@ -568,18 +389,6 @@ export default function ResourceLauncher({
});
};
const handleResourceSelect = useCallback((resource: LauncherResource) => {
setSelectedResource(resource);
setPanelOpen(true);
}, []);
const handlePanelOpenChange = useCallback((open: boolean) => {
setPanelOpen(open);
if (!open) {
setSelectedResource(null);
}
}, []);
const savedViewTabs = views.map((view) => ({
viewId: view.viewId,
name: view.name
@@ -603,8 +412,19 @@ export default function ResourceLauncher({
</div>
);
const renderToolbarFilterSort = () => (
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
/>
<LauncherFilterPopover
orgId={orgId}
selectedSites={selectedSites}
@@ -628,31 +448,15 @@ export default function ResourceLauncher({
})
}
/>
</>
);
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
canResetSystemDefault={canResetSystemDefault}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
onResetSystemDefault={handleResetSystemDefault}
onDeleteView={() => setDeleteDialogOpen(true)}
/>
<LauncherSettingsMenu
config={config}
capabilities={scale.capabilities}
isCompactMode={scale.mode === "compact"}
selectedGroupBy={effectiveConfig.groupBy}
isDefaultView={isDefaultView}
onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/>
<LauncherRefreshButton
onRefresh={refreshData}
@@ -679,9 +483,6 @@ export default function ResourceLauncher({
{isDesktop ? (
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
{renderToolbarSearch("w-64")}
<div className="flex shrink-0 items-center gap-2">
{renderToolbarFilterSort()}
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
{renderToolbarViews()}
</div>
@@ -694,64 +495,22 @@ export default function ResourceLauncher({
<div className="flex items-center gap-2 overflow-x-auto">
{renderToolbarActions()}
</div>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
{renderToolbarSearch("w-full")}
</div>
{renderToolbarFilterSort()}
</div>
{renderToolbarSearch("w-full")}
<div className="overflow-x-auto">
{renderToolbarViews()}
</div>
</div>
)}
{showSearchFirstGate ? (
<LauncherSearchFirstGate layout={config.layout} />
) : showGroupList ? (
<LauncherGroupList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : showFlatResourceList ? (
<LauncherFlatResourceList
orgId={orgId}
activeViewId={activeViewId}
config={effectiveConfig}
onClearFilters={handleClearFilters}
onResourceSelect={handleResourceSelect}
/>
) : null}
<LauncherResourcePanel
open={panelOpen}
onOpenChange={handlePanelOpenChange}
resource={selectedResource}
<LauncherGroupList
orgId={orgId}
isAdmin={isAdmin}
activeViewId={activeViewId}
config={config}
initialGroups={groups}
groupsPagination={groupsPagination}
onClearFilters={handleClearFilters}
/>
{activeSavedView ? (
<ConfirmDeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
string={activeSavedView.name}
title={t("resourceLauncherDeleteViewTitle")}
buttonText={t("resourceLauncherDeleteViewConfirm")}
dialog={<p>{t("resourceLauncherDeleteViewQuestion")}</p>}
onConfirm={async () => {
await deleteViewMutation.mutateAsync(
activeSavedView.viewId
);
}}
/>
) : null}
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent>
<CredenzaHeader>
-21
View File
@@ -1,21 +0,0 @@
import type { SiteResourceData } from "@app/lib/privateResourceForm";
import { createContext } from "react";
export type SiteResourceAccessState = {
roleIds: number[];
userIds: string[];
clientIds: number[];
};
export type SiteResourceContextValue = {
siteResource: SiteResourceData;
updateSiteResource: (updated: Partial<SiteResourceData>) => void;
access: SiteResourceAccessState;
setAccess: (access: SiteResourceAccessState) => void;
};
const SiteResourceContext = createContext<SiteResourceContextValue | null>(
null
);
export default SiteResourceContext;
-12
View File
@@ -1,12 +0,0 @@
import SiteResourceContext from "@app/contexts/siteResourceContext";
import { useContext } from "react";
export function useSiteResourceContext() {
const context = useContext(SiteResourceContext);
if (!context) {
throw new Error(
"useSiteResourceContext must be used within SiteResourceProvider"
);
}
return context;
}
-58
View File
@@ -1,58 +0,0 @@
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
};
}
+4 -4
View File
@@ -10,7 +10,7 @@ function lastViewKey(orgId: string) {
function groupOpenKey(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none"
groupBy: "site" | "label"
) {
return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`;
}
@@ -64,7 +64,7 @@ export function writeLauncherLastView(
export function readLauncherGroupOpenState(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none"
groupBy: "site" | "label"
): 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" | "none",
groupBy: "site" | "label",
groupKey: string,
defaultOpen: boolean
): boolean {
@@ -86,7 +86,7 @@ export function readLauncherGroupOpen(
export function writeLauncherGroupOpen(
orgId: string,
viewId: LauncherActiveViewId,
groupBy: "site" | "label" | "none",
groupBy: "site" | "label",
groupKey: string,
isOpen: boolean
) {
+6 -8
View File
@@ -1,12 +1,5 @@
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
@@ -15,5 +8,10 @@ export function getLauncherResourceAdminHref(
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
}
return getPrivateResourceSettingsHref(orgId, resource.niceId);
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()}`;
}
-93
View File
@@ -1,93 +0,0 @@
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
@@ -1,114 +0,0 @@
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);
}
+9 -56
View File
@@ -1,27 +1,21 @@
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;
@@ -38,56 +32,19 @@ 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,
defaultViewOverrides
null
);
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,
@@ -105,24 +62,20 @@ export async function fetchLauncherPageData(
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) {}
}
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
};
+7 -21
View File
@@ -1,9 +1,7 @@
import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage";
import {
defaultLauncherViewConfig,
getEffectiveDefaultLauncherConfig,
parseIdListParam,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "@server/routers/launcher/types";
@@ -66,13 +64,10 @@ export function isLauncherConfigEqual(
export function getLauncherUrlBaseConfig(
viewId: LauncherActiveViewId,
views: LauncherViewRecord[],
defaultViewOverrides?: LauncherDefaultViewOverrides
views: LauncherViewRecord[]
): LauncherViewConfig {
if (viewId === "default") {
return defaultViewOverrides
? getEffectiveDefaultLauncherConfig(defaultViewOverrides)
: defaultLauncherViewConfig;
return defaultLauncherViewConfig;
}
const savedView = views.find((view) => view.viewId === viewId);
@@ -118,7 +113,7 @@ function parseConfigOverrides(
}
const groupBy = searchParams.get("groupBy");
if (groupBy === "site" || groupBy === "label" || groupBy === "none") {
if (groupBy === "site" || groupBy === "label") {
overrides.groupBy = groupBy;
}
@@ -177,8 +172,7 @@ function isValidActiveViewId(
export function resolveLauncherStateFromUrl(
searchParams: URLSearchParams,
views: LauncherViewRecord[],
fallbackViewId: LauncherActiveViewId | null,
defaultViewOverrides?: LauncherDefaultViewOverrides
fallbackViewId: LauncherActiveViewId | null
): ResolvedLauncherState {
const parsed = parseLauncherUrlState(searchParams);
@@ -194,26 +188,18 @@ export function resolveLauncherStateFromUrl(
: "default";
}
const savedConfig = getLauncherUrlBaseConfig(
activeViewId,
views,
defaultViewOverrides
);
const savedConfig = getLauncherUrlBaseConfig(activeViewId, views);
let config: LauncherViewConfig;
if (hasLauncherConfigParams(searchParams)) {
config = resolveLauncherConfig(
getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
),
defaultLauncherViewConfig,
parsed.configOverrides
);
} else if (activeViewId !== "default") {
config = savedConfig;
} else {
config = getEffectiveDefaultLauncherConfig(
defaultViewOverrides ?? { personal: null, orgWide: null }
);
config = defaultLauncherViewConfig;
}
return {
-656
View File
@@ -1,656 +0,0 @@
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(),
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
};
}
+3 -65
View File
@@ -50,16 +50,11 @@ 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";
@@ -1194,13 +1189,13 @@ export const launcherQueries = {
const res = await meta!.api.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/org/${orgId}/launcher/views`, { signal });
return res.data.data;
return res.data.data.views;
}
}),
sites: ({
orgId,
query,
perPage = 20
perPage = 500
}: {
orgId: string;
query?: string;
@@ -1232,7 +1227,7 @@ export const launcherQueries = {
labels: ({
orgId,
query,
perPage = 20
perPage = 500
}: {
orgId: string;
query?: string;
@@ -1303,62 +1298,5 @@ 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
};
}
})
};
-163
View File
@@ -1,163 +0,0 @@
"use client";
import SiteResourceContext, {
type SiteResourceAccessState
} from "@app/contexts/siteResourceContext";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import {
accessTagsToIds,
type PrivateResourceAccessTag,
type PrivateResourceClient,
type SiteResourceData
} from "@app/lib/privateResourceForm";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type SiteResourceProviderProps = {
children: React.ReactNode;
siteResource: SiteResourceData;
};
export function SiteResourceProvider({
children,
siteResource: serverSiteResource
}: SiteResourceProviderProps) {
const t = useTranslations();
const [siteResource, setSiteResource] =
useState<SiteResourceData>(serverSiteResource);
useEffect(() => {
setSiteResource(serverSiteResource);
}, [serverSiteResource]);
const resourceRolesQuery = useQuery(
resourceQueries.siteResourceRoles({
siteResourceId: siteResource.id
})
);
const resourceUsersQuery = useQuery(
resourceQueries.siteResourceUsers({
siteResourceId: siteResource.id
})
);
const resourceClientsQuery = useQuery(
resourceQueries.siteResourceClients({
siteResourceId: siteResource.id
})
);
const access = useMemo<SiteResourceAccessState>(() => {
const roles = (resourceRolesQuery.data ?? []) as {
roleId: number;
name: string;
}[];
const users = (resourceUsersQuery.data ?? []) as {
userId: string;
}[];
const clients = (resourceClientsQuery.data ?? []) as {
clientId: number;
}[];
return {
roleIds: roles
.filter((r) => r.name !== "Admin")
.map((r) => r.roleId),
userIds: users.map((u) => u.userId),
clientIds: clients.map((c) => c.clientId)
};
}, [
resourceRolesQuery.data,
resourceUsersQuery.data,
resourceClientsQuery.data
]);
const [accessOverride, setAccessOverride] =
useState<SiteResourceAccessState | null>(null);
const resolvedAccess = accessOverride ?? access;
const updateSiteResource = (updated: Partial<SiteResourceData>) => {
if (!siteResource) {
throw new Error(t("resourceErrorNoUpdate"));
}
setSiteResource((prev) => ({ ...prev, ...updated }));
};
return (
<SiteResourceContext.Provider
value={{
siteResource,
updateSiteResource,
access: resolvedAccess,
setAccess: setAccessOverride
}}
>
{children}
</SiteResourceContext.Provider>
);
}
export function useAccessFormDefaults(orgId: string, siteResourceId: number) {
const resourceRolesQuery = useQuery(
resourceQueries.siteResourceRoles({ siteResourceId })
);
const resourceUsersQuery = useQuery(
resourceQueries.siteResourceUsers({ siteResourceId })
);
const resourceClientsQuery = useQuery(
resourceQueries.siteResourceClients({ siteResourceId })
);
const clientsQuery = useQuery(
orgQueries.machineClients({
orgId,
perPage: 1
})
);
const loading =
resourceRolesQuery.isLoading ||
resourceUsersQuery.isLoading ||
resourceClientsQuery.isLoading;
const roles: PrivateResourceAccessTag[] = (
(resourceRolesQuery.data ?? []) as { roleId: number; name: string }[]
)
.map((i) => ({ id: i.roleId.toString(), text: i.name }))
.filter((r) => r.text !== "Admin");
const users: PrivateResourceAccessTag[] = (
(resourceUsersQuery.data ?? []) as {
userId: string;
email?: string;
username?: string;
type?: string;
idpName?: string;
}[]
).map((i) => ({
id: i.userId.toString(),
text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
}));
const clients: PrivateResourceClient[] = [
...((resourceClientsQuery.data ?? []) as {
clientId: number;
name: string;
}[])
];
const allClients = (clientsQuery.data ?? []).filter((c) => !c.userId);
return {
loading,
roles,
users,
clients,
hasMachineClients: allClients.length > 0,
accessIds: accessTagsToIds({ roles, users, clients })
};
}
export default SiteResourceProvider;