mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-07 22:55:08 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b59ed46e72 | |||
| d4c52bbf2f | |||
| 87f50bf0cc | |||
| 2c66da1b19 | |||
| ab19955502 | |||
| 1db9dcec81 | |||
| 49c2d3163e | |||
| 45b9e13a13 |
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.6
|
image: traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: service:gerbil # Ports appear on the gerbil service
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ services:
|
|||||||
- 80:80{{end}}
|
- 80:80{{end}}
|
||||||
|
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.7
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
|
|||||||
+5
-38
@@ -266,8 +266,6 @@
|
|||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new 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",
|
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
"resourceCreateGeneral": "General",
|
"resourceCreateGeneral": "General",
|
||||||
@@ -866,8 +864,8 @@
|
|||||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
"policyAuthHeaderAuthSummary": "Header configured",
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
"policyAuthHeaderName": "Username",
|
"policyAuthHeaderName": "Header name",
|
||||||
"policyAuthHeaderValue": "Password",
|
"policyAuthHeaderValue": "Expected value",
|
||||||
"policyAuthSetPasscode": "Set Passcode",
|
"policyAuthSetPasscode": "Set Passcode",
|
||||||
"policyAuthSetPincode": "Set PIN Code",
|
"policyAuthSetPincode": "Set PIN Code",
|
||||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||||
@@ -2319,12 +2317,6 @@
|
|||||||
"createInternalResourceDialogClose": "Close",
|
"createInternalResourceDialogClose": "Close",
|
||||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
"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",
|
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||||
"createInternalResourceDialogName": "Name",
|
"createInternalResourceDialogName": "Name",
|
||||||
"createInternalResourceDialogSite": "Site",
|
"createInternalResourceDialogSite": "Site",
|
||||||
@@ -3585,24 +3577,18 @@
|
|||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"resourceLauncherTitle": "Resource Launcher",
|
"resourceLauncherTitle": "Resource Launcher",
|
||||||
"resourceSidebarLauncherTitle": "Launcher",
|
"resourceLauncherDescription": "View resource details and launch them from one place",
|
||||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
"resourceLauncherSearchPlaceholder": "Search all sites...",
|
||||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
|
||||||
"resourceLauncherDefaultView": "Default",
|
"resourceLauncherDefaultView": "Default",
|
||||||
"resourceLauncherSaveView": "Save View",
|
"resourceLauncherSaveView": "Save View",
|
||||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
||||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
|
||||||
"resourceLauncherResetView": "Reset View",
|
"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",
|
"resourceLauncherSaveAsNewView": "Save as New View",
|
||||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
||||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
||||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
||||||
"resourceLauncherMakePersonal": "Make Personal",
|
"resourceLauncherMakePersonal": "Make Personal",
|
||||||
"resourceLauncherFilter": "Filter",
|
"resourceLauncherFilter": "Filter",
|
||||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
|
||||||
"resourceLauncherSort": "Sort",
|
"resourceLauncherSort": "Sort",
|
||||||
"resourceLauncherSortAscending": "Sort ascending",
|
"resourceLauncherSortAscending": "Sort ascending",
|
||||||
"resourceLauncherSortDescending": "Sort descending",
|
"resourceLauncherSortDescending": "Sort descending",
|
||||||
@@ -3610,7 +3596,6 @@
|
|||||||
"resourceLauncherGroupBy": "Group By",
|
"resourceLauncherGroupBy": "Group By",
|
||||||
"resourceLauncherGroupBySite": "Site",
|
"resourceLauncherGroupBySite": "Site",
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
"resourceLauncherGroupByLabel": "Label",
|
||||||
"resourceLauncherGroupByNone": "None",
|
|
||||||
"resourceLauncherLayout": "Layout",
|
"resourceLauncherLayout": "Layout",
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
"resourceLauncherLayoutGrid": "Grid",
|
||||||
"resourceLauncherLayoutList": "List",
|
"resourceLauncherLayoutList": "List",
|
||||||
@@ -3618,20 +3603,8 @@
|
|||||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
"resourceLauncherShowSiteTags": "Show Site Tags",
|
||||||
"resourceLauncherShowRecents": "Show Recents",
|
"resourceLauncherShowRecents": "Show Recents",
|
||||||
"resourceLauncherDeleteView": "Delete View",
|
"resourceLauncherDeleteView": "Delete View",
|
||||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
|
||||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
|
||||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
"resourceLauncherResourceDetailsDescription": "View details 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",
|
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
"resourceLauncherUnlabeled": "Unlabeled",
|
||||||
"resourceLauncherNoSite": "No Site",
|
"resourceLauncherNoSite": "No Site",
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
||||||
@@ -3640,12 +3613,6 @@
|
|||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
||||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
"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.",
|
"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",
|
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
"resourceLauncherViewNamePlaceholder": "View name",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import config from "@server/lib/config";
|
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
@@ -40,28 +39,6 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// Set via a runtime SET command rather than the `options: "-c
|
|
||||||
// jit=off"` startup parameter: connections in SaaS mode go through
|
|
||||||
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
|
||||||
// options with a protocol_violation (08P01) error.
|
|
||||||
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
|
||||||
client.query("SET jit = off").catch((err: Error) => {
|
|
||||||
console.warn(
|
|
||||||
`Failed to set jit=off on ${label} client: ${err.message}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,6 @@ export const launcherViews = pgTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
isDefault: boolean("isDefault").notNull().default(false),
|
|
||||||
createdAt: varchar("createdAt").notNull(),
|
createdAt: varchar("createdAt").notNull(),
|
||||||
updatedAt: varchar("updatedAt").notNull()
|
updatedAt: varchar("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -233,9 +233,6 @@ export const launcherViews = sqliteTable("launcherViews", {
|
|||||||
}),
|
}),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
config: text("config").notNull(),
|
config: text("config").notNull(),
|
||||||
isDefault: integer("isDefault", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: text("createdAt").notNull(),
|
createdAt: text("createdAt").notNull(),
|
||||||
updatedAt: text("updatedAt").notNull()
|
updatedAt: text("updatedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export enum TierFeature {
|
|||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
|
Labels = "labels",
|
||||||
NewtAutoUpdate = "newtAutoUpdate",
|
NewtAutoUpdate = "newtAutoUpdate",
|
||||||
ResourcePolicies = "resourcePolicies",
|
ResourcePolicies = "resourcePolicies",
|
||||||
AdvancedPublicResources = "advancedPublicResources",
|
AdvancedPublicResources = "advancedPublicResources",
|
||||||
@@ -30,6 +31,7 @@ export enum TierFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
|
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -184,8 +184,7 @@ export const configSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default(5000),
|
.default(5000)
|
||||||
jit_mode: z.boolean().default(true)
|
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({})
|
.prefault({})
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getOrgTierData } from "#private/lib/billing";
|
import { getOrgTierData } from "#private/lib/billing";
|
||||||
import { Tier } from "@server/types/Tiers";
|
import { Tier } from "@server/types/Tiers";
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export function verifyValidSubscription(tiers: Tier[]) {
|
export function verifyValidSubscription(tiers: Tier[]) {
|
||||||
return async function (
|
return async function (
|
||||||
@@ -31,9 +30,9 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orgId =
|
const orgId =
|
||||||
req.params?.orgId ||
|
req.params.orgId ||
|
||||||
req.body?.orgId ||
|
req.body.orgId ||
|
||||||
req.query?.orgId ||
|
req.query.orgId ||
|
||||||
req.userOrgId;
|
req.userOrgId;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
@@ -66,7 +65,6 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e);
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
|
import * as labels from "#private/routers/labels";
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
import * as policy from "#private/routers/policy";
|
import * as policy from "#private/routers/policy";
|
||||||
@@ -809,6 +810,59 @@ authenticated.get(
|
|||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
||||||
|
labels.listOrgLabels
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/labels",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
||||||
|
labels.createOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.patch(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
||||||
|
labels.updateOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/org/:orgId/label/:labelId",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
||||||
|
labels.deleteOrgLabel
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/attach",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
||||||
|
labels.attachLabelToItem
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.put(
|
||||||
|
"/org/:orgId/label/:labelId/detach",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyValidSubscription(tierMatrix.labels),
|
||||||
|
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
||||||
|
labels.detachLabelFromItem
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
+13
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientLabels,
|
clientLabels,
|
||||||
+12
@@ -1,3 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
+12
@@ -1,3 +1,15 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
import { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
+13
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
clientLabels,
|
clientLabels,
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./listOrgLabels";
|
||||||
|
export * from "./createOrgLabel";
|
||||||
|
export * from "./updateOrgLabel";
|
||||||
|
export * from "./attachLabelToItem";
|
||||||
|
export * from "./detachLabelFromItem";
|
||||||
|
export * from "./deleteOrgLabel";
|
||||||
+13
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
+13
@@ -1,3 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
import { db, labels } from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
|
|||||||
source: "API"
|
source: "API"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -304,6 +304,11 @@ export async function listClients(
|
|||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const conditions = [
|
const conditions = [
|
||||||
and(
|
and(
|
||||||
@@ -336,7 +341,7 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
conditions.push(or(...filterAggregates));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
clients.clientId,
|
clients.clientId,
|
||||||
@@ -356,20 +361,25 @@ export async function listClients(
|
|||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${clients.name})`, q),
|
like(sql`LOWER(${clients.name})`, q),
|
||||||
like(sql`LOWER(${clients.niceId})`, q),
|
like(sql`LOWER(${clients.niceId})`, q)
|
||||||
inArray(
|
|
||||||
clients.clientId,
|
|
||||||
db
|
|
||||||
.select({ id: clientLabels.clientId })
|
|
||||||
.from(clientLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, clientLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
clients.clientId,
|
||||||
|
db
|
||||||
|
.select({ id: clientLabels.clientId })
|
||||||
|
.from(clientLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, clientLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +414,7 @@ export async function listClients(
|
|||||||
clientId: number;
|
clientId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
||||||
labelsForClients = await db
|
labelsForClients = await db
|
||||||
.select({
|
.select({
|
||||||
labelId: labels.labelId,
|
labelId: labels.labelId,
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { build } from "@server/build";
|
|||||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||||
import { logActionAudit } from "#dynamic/middlewares";
|
import { logActionAudit } from "#dynamic/middlewares";
|
||||||
import { checkRoundTripMessage } from "./ws";
|
import { checkRoundTripMessage } from "./ws";
|
||||||
import * as labels from "@server/routers/labels";
|
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -328,14 +327,6 @@ authenticated.get(
|
|||||||
siteResource.listAllSiteResourcesByOrg
|
siteResource.listAllSiteResourcesByOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/site-resource/:siteResourceId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifySiteResourceAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getSiteResource),
|
|
||||||
siteResource.getSiteResource
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/site-resource/:siteResourceId",
|
"/site-resource/:siteResourceId",
|
||||||
verifySiteResourceAccess,
|
verifySiteResourceAccess,
|
||||||
@@ -479,12 +470,6 @@ authenticated.get(
|
|||||||
launcher.listLauncherGroups
|
launcher.listLauncherGroups
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/launcher/scale",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.listLauncherScale
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/launcher/resources",
|
"/org/:orgId/launcher/resources",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -509,12 +494,6 @@ authenticated.get(
|
|||||||
launcher.listLauncherViews
|
launcher.listLauncherViews
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/launcher/invalidate-cache",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.invalidateLauncherCache
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/launcher/views",
|
"/org/:orgId/launcher/views",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -527,18 +506,6 @@ authenticated.put(
|
|||||||
launcher.updateLauncherView
|
launcher.updateLauncherView
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/launcher/default-view",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.upsertLauncherDefaultView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/launcher/default-view",
|
|
||||||
verifyOrgAccess,
|
|
||||||
launcher.deleteLauncherDefaultView
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/org/:orgId/launcher/views/:viewId",
|
"/org/:orgId/launcher/views/:viewId",
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -1336,48 +1303,6 @@ authenticated.get(
|
|||||||
|
|
||||||
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
|
||||||
labels.listOrgLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
|
||||||
labels.createOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.patch(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
|
||||||
labels.updateOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
|
||||||
labels.deleteOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/attach",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
|
||||||
labels.attachLabelToItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/detach",
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
|
||||||
labels.detachLabelFromItem
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./listOrgLabels";
|
|
||||||
export * from "./createOrgLabel";
|
|
||||||
export * from "./updateOrgLabel";
|
|
||||||
export * from "./attachLabelToItem";
|
|
||||||
export * from "./detachLabelFromItem";
|
|
||||||
export * from "./deleteOrgLabel";
|
|
||||||
@@ -79,8 +79,7 @@ export async function createLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: created.createdAt,
|
createdAt: created.createdAt,
|
||||||
updatedAt: created.updatedAt,
|
updatedAt: created.updatedAt,
|
||||||
isOrgWide: created.userId == null,
|
isOrgWide: created.userId == null
|
||||||
isDefault: created.isDefault
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
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 isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from "./types";
|
export * from "./types";
|
||||||
export { listLauncherGroups } from "./listLauncherGroups";
|
export { listLauncherGroups } from "./listLauncherGroups";
|
||||||
export { listLauncherScale } from "./listLauncherScale";
|
|
||||||
export { listLauncherResources } from "./listLauncherResources";
|
export { listLauncherResources } from "./listLauncherResources";
|
||||||
export { listLauncherSites } from "./listLauncherSites";
|
export { listLauncherSites } from "./listLauncherSites";
|
||||||
export { listLauncherLabels } from "./listLauncherLabels";
|
export { listLauncherLabels } from "./listLauncherLabels";
|
||||||
@@ -8,6 +7,3 @@ export { listLauncherViews } from "./listLauncherViews";
|
|||||||
export { createLauncherView } from "./createLauncherView";
|
export { createLauncherView } from "./createLauncherView";
|
||||||
export { updateLauncherView } from "./updateLauncherView";
|
export { updateLauncherView } from "./updateLauncherView";
|
||||||
export { deleteLauncherView } from "./deleteLauncherView";
|
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 });
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { db } from "@server/db";
|
||||||
import { alias, db } from "@server/db";
|
|
||||||
import {
|
import {
|
||||||
exitNodes,
|
exitNodes,
|
||||||
labels,
|
labels,
|
||||||
launcherViews,
|
launcherViews,
|
||||||
resourceLabels,
|
resourceLabels,
|
||||||
resourcePolicies,
|
|
||||||
resources,
|
resources,
|
||||||
rolePolicies,
|
rolePolicies,
|
||||||
roleResources,
|
roleResources,
|
||||||
@@ -32,17 +30,14 @@ import {
|
|||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
like,
|
like,
|
||||||
not,
|
|
||||||
or,
|
or,
|
||||||
sql,
|
sql
|
||||||
type SQL
|
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
formatPublicResourceAccess,
|
formatPublicResourceAccess,
|
||||||
formatSiteResourceAccess
|
formatSiteResourceAccess
|
||||||
} from "./formatLauncherAccess";
|
} from "./formatLauncherAccess";
|
||||||
import {
|
import {
|
||||||
LAUNCHER_FLAT_GROUP_KEY,
|
|
||||||
LAUNCHER_NO_SITE_GROUP_KEY,
|
LAUNCHER_NO_SITE_GROUP_KEY,
|
||||||
LAUNCHER_UNLABELED_GROUP_KEY,
|
LAUNCHER_UNLABELED_GROUP_KEY,
|
||||||
type LauncherFilterListQuery,
|
type LauncherFilterListQuery,
|
||||||
@@ -64,68 +59,6 @@ export type AccessibleIds = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
|
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(
|
function launcherAccessibleIdsCacheKey(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -136,48 +69,6 @@ function launcherAccessibleIdsCacheKey(
|
|||||||
return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`;
|
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(
|
async function resolveAccessibleIdsUncached(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -189,8 +80,7 @@ async function resolveAccessibleIdsUncached(
|
|||||||
directPolicyResourceResults,
|
directPolicyResourceResults,
|
||||||
rolePolicyResourceResults,
|
rolePolicyResourceResults,
|
||||||
directSiteResourceResults,
|
directSiteResourceResults,
|
||||||
roleSiteResourceResults,
|
roleSiteResourceResults
|
||||||
orgNonSsoPublicResources
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ resourceId: userResources.resourceId })
|
.select({ resourceId: userResources.resourceId })
|
||||||
@@ -259,8 +149,7 @@ async function resolveAccessibleIdsUncached(
|
|||||||
})
|
})
|
||||||
.from(roleSiteResources)
|
.from(roleSiteResources)
|
||||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([])
|
||||||
fetchOrgNonSsoPublicResourceIds(orgId)
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -269,8 +158,7 @@ async function resolveAccessibleIdsUncached(
|
|||||||
...directResources.map((r) => r.resourceId),
|
...directResources.map((r) => r.resourceId),
|
||||||
...roleResourceResults.map((r) => r.resourceId),
|
...roleResourceResults.map((r) => r.resourceId),
|
||||||
...directPolicyResourceResults.map((r) => r.resourceId),
|
...directPolicyResourceResults.map((r) => r.resourceId),
|
||||||
...rolePolicyResourceResults.map((r) => r.resourceId),
|
...rolePolicyResourceResults.map((r) => r.resourceId)
|
||||||
...orgNonSsoPublicResources
|
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
siteResourceIds: Array.from(
|
siteResourceIds: Array.from(
|
||||||
@@ -306,22 +194,10 @@ function searchPattern(query: string) {
|
|||||||
return `%${query.trim()}%`;
|
return `%${query.trim()}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function combineOrConditions(
|
function buildSearchConditionForPublic(
|
||||||
...conditions: (SQL | undefined)[]
|
query: string,
|
||||||
): SQL | undefined {
|
labelsFeatureEnabled: boolean
|
||||||
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) {
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -329,31 +205,32 @@ function buildSearchConditionForPublic(query: string) {
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${resources.name})`, pattern),
|
like(sql`LOWER(${resources.name})`, pattern),
|
||||||
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
like(sql`LOWER(${resources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(cast(${resources.proxyPort} as text))`, 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))
|
|
||||||
),
|
|
||||||
inArray(
|
|
||||||
resources.resourceId,
|
|
||||||
db
|
|
||||||
.select({ id: resourceLabels.resourceId })
|
|
||||||
.from(resourceLabels)
|
|
||||||
.innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (labelsFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
db
|
||||||
|
.select({ id: resourceLabels.resourceId })
|
||||||
|
.from(resourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, resourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchConditionForSiteResource(query: string) {
|
function buildSearchConditionForSiteResource(
|
||||||
|
query: string,
|
||||||
|
labelsFeatureEnabled: boolean
|
||||||
|
) {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -361,99 +238,32 @@ function buildSearchConditionForSiteResource(query: string) {
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${siteResources.name})`, pattern),
|
like(sql`LOWER(${siteResources.name})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.destination})`, 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.alias})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
like(sql`LOWER(${siteResources.fullDomain})`, pattern),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, pattern),
|
like(sql`LOWER(${siteResources.aliasAddress})`, pattern)
|
||||||
inArray(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
db
|
|
||||||
.select({ id: siteResourceLabels.siteResourceId })
|
|
||||||
.from(siteResourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteResourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (labelsFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
db
|
||||||
|
.select({ id: siteResourceLabels.siteResourceId })
|
||||||
|
.from(siteResourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteResourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function filterPublicResourceIdsByTextSearch(
|
async function labelsEnabled(orgId: string): Promise<boolean> {
|
||||||
orgId: string,
|
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
||||||
resourceIds: number[],
|
|
||||||
query: string
|
|
||||||
): Promise<number[]> {
|
|
||||||
if (!query.trim() || resourceIds.length === 0) {
|
|
||||||
return resourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
|
||||||
buildSearchConditionForPublic(query),
|
|
||||||
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
|
|
||||||
): Promise<number[]> {
|
|
||||||
if (!query.trim() || siteResourceIds.length === 0) {
|
|
||||||
return siteResourceIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
|
||||||
buildSearchConditionForSiteResource(query),
|
|
||||||
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 fetchLabelsForResources(
|
async function fetchLabelsForResources(
|
||||||
@@ -467,6 +277,10 @@ async function fetchLabelsForResources(
|
|||||||
const byResourceId = new Map<number, LauncherLabel[]>();
|
const byResourceId = new Map<number, LauncherLabel[]>();
|
||||||
const bySiteResourceId = new Map<number, LauncherLabel[]>();
|
const bySiteResourceId = new Map<number, LauncherLabel[]>();
|
||||||
|
|
||||||
|
if (!(await labelsEnabled(orgId))) {
|
||||||
|
return { byResourceId, bySiteResourceId };
|
||||||
|
}
|
||||||
|
|
||||||
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
|
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
|
||||||
resourceIds.length === 0
|
resourceIds.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -542,8 +356,15 @@ async function listSiteGroups(
|
|||||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
const labelFilterIds = parseIdListParam(query.labelIds);
|
const labelFilterIds = parseIdListParam(query.labelIds);
|
||||||
const searchPublic = buildSearchConditionForPublic(query.query);
|
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
||||||
const searchSite = buildSearchConditionForSiteResource(query.query);
|
const searchPublic = buildSearchConditionForPublic(
|
||||||
|
query.query,
|
||||||
|
labelsFeatureEnabled
|
||||||
|
);
|
||||||
|
const searchSite = buildSearchConditionForSiteResource(
|
||||||
|
query.query,
|
||||||
|
labelsFeatureEnabled
|
||||||
|
);
|
||||||
const siteCountMap = new Map<number, SiteGroupRow>();
|
const siteCountMap = new Map<number, SiteGroupRow>();
|
||||||
|
|
||||||
if (accessible.resourceIds.length > 0) {
|
if (accessible.resourceIds.length > 0) {
|
||||||
@@ -767,8 +588,9 @@ async function listSiteGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
groups,
|
groups: groups.slice(offset, offset + query.pageSize),
|
||||||
total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -786,6 +608,10 @@ async function listLabelGroups(
|
|||||||
>();
|
>();
|
||||||
let unlabeledCount = 0;
|
let unlabeledCount = 0;
|
||||||
|
|
||||||
|
if (!(await labelsEnabled(orgId))) {
|
||||||
|
return { groups: [], total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
const matchesLabelFilters = (labelId: number) =>
|
const matchesLabelFilters = (labelId: number) =>
|
||||||
labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
|
labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
|
||||||
|
|
||||||
@@ -795,7 +621,7 @@ async function listLabelGroups(
|
|||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true)
|
||||||
];
|
];
|
||||||
const searchPublic = buildSearchConditionForPublic(query.query);
|
const searchPublic = buildSearchConditionForPublic(query.query, true);
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
publicConditions.push(searchPublic);
|
publicConditions.push(searchPublic);
|
||||||
}
|
}
|
||||||
@@ -859,7 +685,10 @@ async function listLabelGroups(
|
|||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true)
|
||||||
];
|
];
|
||||||
const searchSite = buildSearchConditionForSiteResource(query.query);
|
const searchSite = buildSearchConditionForSiteResource(
|
||||||
|
query.query,
|
||||||
|
true
|
||||||
|
);
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
siteConditions.push(searchSite);
|
siteConditions.push(searchSite);
|
||||||
}
|
}
|
||||||
@@ -959,53 +788,26 @@ async function listLabelGroups(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const total = groups.length;
|
const total = groups.length;
|
||||||
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
groups,
|
groups: groups.slice(offset, offset + query.pageSize),
|
||||||
total
|
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(
|
export async function listLauncherGroupsForUser(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery
|
query: LauncherListQuery
|
||||||
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
): Promise<{ groups: LauncherGroup[]; total: number }> {
|
||||||
const queryHash = launcherGroupsQueryHash(userRoleIds, query);
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
|
|
||||||
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
|
|
||||||
|
|
||||||
let result = cached;
|
if (query.groupBy === "label") {
|
||||||
if (!result) {
|
return listLabelGroups(orgId, accessible, query);
|
||||||
result = await listLauncherGroupsForUserUncached(
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
userRoleIds,
|
|
||||||
query
|
|
||||||
);
|
|
||||||
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const offset = (query.page - 1) * query.pageSize;
|
return listSiteGroups(orgId, accessible, query);
|
||||||
return {
|
|
||||||
groups: result.groups.slice(offset, offset + query.pageSize),
|
|
||||||
total: result.total
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapPublicResources(
|
async function mapPublicResources(
|
||||||
@@ -1216,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(
|
function sortLauncherResources(
|
||||||
items: LauncherResource[],
|
items: LauncherResource[],
|
||||||
order: "asc" | "desc"
|
order: "asc" | "desc"
|
||||||
@@ -1228,12 +1050,12 @@ function sortLauncherResources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listLauncherResourcesForUserUncached(
|
export async function listLauncherResourcesForUser(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
userRoleIds: number[],
|
userRoleIds: number[],
|
||||||
query: LauncherListQuery & { groupKey: string }
|
query: LauncherListQuery & { groupKey: string }
|
||||||
): Promise<LauncherResourcesCacheEntry> {
|
): Promise<{ resources: LauncherResource[]; total: number }> {
|
||||||
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
|
||||||
|
|
||||||
const siteFilterIds = parseIdListParam(query.siteIds);
|
const siteFilterIds = parseIdListParam(query.siteIds);
|
||||||
@@ -1306,23 +1128,6 @@ async function listLauncherResourcesForUserUncached(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.query.trim()) {
|
|
||||||
if (filteredResourceIds.length > 0) {
|
|
||||||
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
|
||||||
orgId,
|
|
||||||
filteredResourceIds,
|
|
||||||
query.query
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (filteredSiteResourceIds.length > 0) {
|
|
||||||
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
|
||||||
orgId,
|
|
||||||
filteredSiteResourceIds,
|
|
||||||
query.query
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const labelMaps = await fetchLabelsForResources(
|
const labelMaps = await fetchLabelsForResources(
|
||||||
orgId,
|
orgId,
|
||||||
filteredResourceIds,
|
filteredResourceIds,
|
||||||
@@ -1354,48 +1159,21 @@ async function listLauncherResourcesForUserUncached(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
let items = [...publicItems, ...siteItems];
|
let items = [...publicItems, ...siteItems];
|
||||||
|
items = filterResourcesBySearch(items, query.query);
|
||||||
|
|
||||||
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
|
if (query.groupBy === "label") {
|
||||||
if (query.groupBy === "label") {
|
items = filterResourcesByLabel(items, query.groupKey);
|
||||||
items = filterResourcesByLabel(items, query.groupKey);
|
} else if (query.groupBy === "site") {
|
||||||
} else if (query.groupBy === "site") {
|
items = filterResourcesBySite(items, query.groupKey);
|
||||||
items = filterResourcesBySite(items, query.groupKey);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
items = sortLauncherResources(items, query.order);
|
items = sortLauncherResources(items, query.order);
|
||||||
|
|
||||||
return {
|
const total = items.length;
|
||||||
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 offset = (query.page - 1) * query.pageSize;
|
const offset = (query.page - 1) * query.pageSize;
|
||||||
return {
|
return {
|
||||||
resources: result.items.slice(offset, offset + query.pageSize),
|
resources: items.slice(offset, offset + query.pageSize),
|
||||||
total: result.total
|
total
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1518,6 +1296,10 @@ async function collectAccessibleLabels(
|
|||||||
): Promise<Map<number, LauncherLabel>> {
|
): Promise<Map<number, LauncherLabel>> {
|
||||||
const labelMap = new Map<number, LauncherLabel>();
|
const labelMap = new Map<number, LauncherLabel>();
|
||||||
|
|
||||||
|
if (!(await labelsEnabled(orgId))) {
|
||||||
|
return labelMap;
|
||||||
|
}
|
||||||
|
|
||||||
if (accessible.resourceIds.length > 0) {
|
if (accessible.resourceIds.length > 0) {
|
||||||
const publicConditions = [
|
const publicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
|
|||||||
@@ -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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,22 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { and, eq, isNull, or } from "drizzle-orm";
|
import { and, eq, isNull, or } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import {
|
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types";
|
||||||
extractDefaultViewOverrides,
|
|
||||||
listVisibleLauncherViews
|
function mapViewRow(
|
||||||
} from "./launcherDefaultView";
|
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(
|
export async function listLauncherViews(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -39,8 +51,7 @@ export async function listLauncherViews(
|
|||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {
|
data: {
|
||||||
views: listVisibleLauncherViews(rows),
|
views: rows.map(mapViewRow)
|
||||||
defaultViewOverrides: extractDefaultViewOverrides(rows)
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
|
||||||
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
|
||||||
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
|
|
||||||
|
|
||||||
export const launcherViewConfigSchema = z.object({
|
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"),
|
layout: z.enum(["grid", "list"]).default("grid"),
|
||||||
sortBy: z.literal("name").default("name"),
|
sortBy: z.literal("name").default("name"),
|
||||||
order: z.enum(["asc", "desc"]).default("asc"),
|
order: z.enum(["asc", "desc"]).default("asc"),
|
||||||
@@ -89,31 +88,10 @@ export type LauncherViewRecord = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
isOrgWide: boolean;
|
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 = {
|
export type ListLauncherViewsResponse = {
|
||||||
views: LauncherViewRecord[];
|
views: LauncherViewRecord[];
|
||||||
defaultViewOverrides: LauncherDefaultViewOverrides;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const launcherFilterListQuerySchema = z.strictObject({
|
export const launcherFilterListQuerySchema = z.strictObject({
|
||||||
@@ -122,8 +100,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.catch(20)
|
.catch(500)
|
||||||
.default(20),
|
.default(500),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default("")
|
query: z.string().optional().default("")
|
||||||
});
|
});
|
||||||
@@ -160,7 +138,7 @@ export const launcherListQuerySchema = z.strictObject({
|
|||||||
.default(20),
|
.default(20),
|
||||||
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
|
||||||
query: z.string().optional().default(""),
|
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(),
|
groupKey: z.string().optional(),
|
||||||
siteIds: z.string().optional(),
|
siteIds: z.string().optional(),
|
||||||
labelIds: z.string().optional(),
|
labelIds: z.string().optional(),
|
||||||
@@ -185,32 +163,3 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
|||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
| { type: "default" }
|
| { type: "default" }
|
||||||
| { type: "saved"; viewId: number };
|
| { 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>;
|
|
||||||
|
|||||||
@@ -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 isPersonalView = existing.userId === userId;
|
||||||
const isOrgWideView = existing.userId == null;
|
const isOrgWideView = existing.userId == null;
|
||||||
const canManageOrgWide = await checkUserActionPermission(
|
const canManageOrgWide = await checkUserActionPermission(
|
||||||
@@ -144,8 +135,7 @@ export async function updateLauncherView(
|
|||||||
),
|
),
|
||||||
createdAt: updated.createdAt,
|
createdAt: updated.createdAt,
|
||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
isOrgWide: updated.userId == null,
|
isOrgWide: updated.userId == null
|
||||||
isDefault: updated.isDefault
|
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import {
|
import { resources, resourceWhitelist } from "@server/db";
|
||||||
resources,
|
|
||||||
resourceWhitelist,
|
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyWhiteList
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -108,99 +103,40 @@ export async function addEmailToResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shared policy takes precedence over the resource's inline
|
if (!resource.emailWhitelistEnabled) {
|
||||||
// (default) policy, which takes precedence over the resource's own
|
return next(
|
||||||
// direct whitelist fields. This mirrors the precedence used at
|
createHttpError(
|
||||||
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
HttpCode.BAD_REQUEST,
|
||||||
const policyId =
|
"Email whitelist is not enabled for this resource"
|
||||||
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
)
|
||||||
|
);
|
||||||
if (policyId !== null) {
|
|
||||||
const [policy] = await db
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicies)
|
|
||||||
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
if (!policy) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"Resource policy not found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!policy.emailWhitelistEnabled) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingEntry = await db
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyWhiteList)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
resourcePolicyWhiteList.resourcePolicyId,
|
|
||||||
policyId
|
|
||||||
),
|
|
||||||
eq(resourcePolicyWhiteList.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Email already exists in whitelist"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(resourcePolicyWhiteList).values({
|
|
||||||
email,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!resource.emailWhitelistEnabled) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email already exists in whitelist
|
|
||||||
const existingEntry = await db
|
|
||||||
.select()
|
|
||||||
.from(resourceWhitelist)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceWhitelist.resourceId, resourceId),
|
|
||||||
eq(resourceWhitelist.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
|
||||||
"Email already exists in whitelist"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(resourceWhitelist).values({
|
|
||||||
email,
|
|
||||||
resourceId
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email already exists in whitelist
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Email already exists in whitelist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(resourceWhitelist).values({
|
||||||
|
email,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -96,17 +96,13 @@ export async function getResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shared policy takes precedence over the resource's inline
|
const isInlinePolicy =
|
||||||
// (default) policy, which takes precedence over the resource's own
|
resource.resourcePolicyId === null &&
|
||||||
// direct whitelist fields. This mirrors the precedence used at
|
resource.defaultResourcePolicyId !== null;
|
||||||
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
|
||||||
const policyId =
|
|
||||||
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
|
||||||
|
|
||||||
const whitelist =
|
const whitelist = isInlinePolicy
|
||||||
policyId !== null
|
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
|
||||||
? await queryPolicyWhitelist(policyId)
|
: await queryWhitelist(resourceId);
|
||||||
: await queryWhitelist(resourceId);
|
|
||||||
|
|
||||||
return response<GetResourceWhitelistResponse>(res, {
|
return response<GetResourceWhitelistResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -363,6 +363,11 @@ export async function getUserResources(
|
|||||||
(r) => r.siteResourceId
|
(r) => r.siteResourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
let labelsForResources: Array<{
|
let labelsForResources: Array<{
|
||||||
labelId: number;
|
labelId: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -376,45 +381,49 @@ export async function getUserResources(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
if (isLabelFeatureEnabled) {
|
||||||
resourceIdList.length === 0
|
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
||||||
? Promise.resolve([])
|
resourceIdList.length === 0
|
||||||
: db
|
? Promise.resolve([])
|
||||||
.select({
|
: db
|
||||||
labelId: labels.labelId,
|
.select({
|
||||||
name: labels.name,
|
labelId: labels.labelId,
|
||||||
color: labels.color,
|
name: labels.name,
|
||||||
resourceId: resourceLabels.resourceId
|
color: labels.color,
|
||||||
})
|
resourceId: resourceLabels.resourceId
|
||||||
.from(labels)
|
})
|
||||||
.innerJoin(
|
.from(labels)
|
||||||
resourceLabels,
|
.innerJoin(
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
resourceLabels,
|
||||||
)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId)),
|
|
||||||
siteResourceIdList.length === 0
|
|
||||||
? Promise.resolve([])
|
|
||||||
: db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
siteResourceId: siteResourceLabels.siteResourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteResourceLabels,
|
|
||||||
eq(siteResourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
siteResourceLabels.siteResourceId,
|
|
||||||
siteResourceIdList
|
|
||||||
)
|
)
|
||||||
)
|
.where(
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
inArray(resourceLabels.resourceId, resourceIdList)
|
||||||
]);
|
)
|
||||||
|
.orderBy(asc(resourceLabels.resourceLabelId)),
|
||||||
|
siteResourceIdList.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
siteResourceId: siteResourceLabels.siteResourceId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
siteResourceLabels,
|
||||||
|
eq(siteResourceLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
siteResourceLabels.siteResourceId,
|
||||||
|
siteResourceIdList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for password, pincode, and whitelist protection for each resource
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
const resourcesWithAuth = await Promise.all(
|
const resourcesWithAuth = await Promise.all(
|
||||||
|
|||||||
@@ -484,6 +484,11 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
let accessibleResources: Array<{ resourceId: number }>;
|
let accessibleResources: Array<{ resourceId: number }>;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
@@ -611,8 +616,8 @@ export async function listResources(
|
|||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
or(
|
or(
|
||||||
effectiveSso,
|
eq(effectiveSso, true),
|
||||||
effectiveWhitelist,
|
eq(effectiveWhitelist, true),
|
||||||
not(isNull(effectiveHeaderAuthId)),
|
not(isNull(effectiveHeaderAuthId)),
|
||||||
not(isNull(effectivePincodeId)),
|
not(isNull(effectivePincodeId)),
|
||||||
not(isNull(effectivePasswordId))
|
not(isNull(effectivePasswordId))
|
||||||
@@ -624,8 +629,8 @@ export async function listResources(
|
|||||||
conditions.push(
|
conditions.push(
|
||||||
and(
|
and(
|
||||||
inArray(resources.mode, browserGatewayModes),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
not(effectiveSso),
|
not(eq(effectiveSso, true)),
|
||||||
not(effectiveWhitelist),
|
not(eq(effectiveWhitelist, true)),
|
||||||
isNull(effectiveHeaderAuthId),
|
isNull(effectiveHeaderAuthId),
|
||||||
isNull(effectivePincodeId),
|
isNull(effectivePincodeId),
|
||||||
isNull(effectivePasswordId)
|
isNull(effectivePasswordId)
|
||||||
@@ -671,7 +676,7 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
@@ -692,20 +697,25 @@ export async function listResources(
|
|||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${resources.name})`, q),
|
like(sql`LOWER(${resources.name})`, q),
|
||||||
like(sql`LOWER(${resources.niceId})`, q),
|
like(sql`LOWER(${resources.niceId})`, q),
|
||||||
like(sql`LOWER(${resources.fullDomain})`, q),
|
like(sql`LOWER(${resources.fullDomain})`, q)
|
||||||
inArray(
|
|
||||||
resources.resourceId,
|
|
||||||
db
|
|
||||||
.select({ id: resourceLabels.resourceId })
|
|
||||||
.from(resourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, resourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
db
|
||||||
|
.select({ id: resourceLabels.resourceId })
|
||||||
|
.from(resourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, resourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,23 +747,27 @@ export async function listResources(
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
labelsForResources =
|
if (isLabelFeatureEnabled) {
|
||||||
resourceIdList.length === 0
|
labelsForResources =
|
||||||
? []
|
resourceIdList.length === 0
|
||||||
: await db
|
? []
|
||||||
.select({
|
: await db
|
||||||
labelId: labels.labelId,
|
.select({
|
||||||
name: labels.name,
|
labelId: labels.labelId,
|
||||||
color: labels.color,
|
name: labels.name,
|
||||||
resourceId: resourceLabels.resourceId
|
color: labels.color,
|
||||||
})
|
resourceId: resourceLabels.resourceId
|
||||||
.from(labels)
|
})
|
||||||
.innerJoin(
|
.from(labels)
|
||||||
resourceLabels,
|
.innerJoin(
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
resourceLabels,
|
||||||
)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
)
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
.where(
|
||||||
|
inArray(resourceLabels.resourceId, resourceIdList)
|
||||||
|
)
|
||||||
|
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
|
|||||||
@@ -248,6 +248,11 @@ export async function listUserResourceAliases(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
const whereConditions = [
|
const whereConditions = [
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
@@ -257,7 +262,7 @@ export async function listUserResourceAliases(
|
|||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -305,7 +310,7 @@ export async function listUserResourceAliases(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (siteResourceIdList.length > 0) {
|
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
||||||
labelsForSiteResources = await db
|
labelsForSiteResources = await db
|
||||||
.select({
|
.select({
|
||||||
name: labels.name,
|
name: labels.name,
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import {
|
import { resources, resourceWhitelist } from "@server/db";
|
||||||
resources,
|
|
||||||
resourceWhitelist,
|
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyWhiteList
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -107,110 +102,44 @@ export async function removeEmailFromResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shared policy takes precedence over the resource's inline
|
if (!resource.emailWhitelistEnabled) {
|
||||||
// (default) policy, which takes precedence over the resource's own
|
return next(
|
||||||
// direct whitelist fields. This mirrors the precedence used at
|
createHttpError(
|
||||||
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
HttpCode.BAD_REQUEST,
|
||||||
const policyId =
|
"Email whitelist is not enabled for this resource"
|
||||||
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
)
|
||||||
|
);
|
||||||
if (policyId !== null) {
|
|
||||||
const [policy] = await db
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicies)
|
|
||||||
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
if (!policy) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"Resource policy not found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!policy.emailWhitelistEnabled) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingEntry = await db
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyWhiteList)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
resourcePolicyWhiteList.resourcePolicyId,
|
|
||||||
policyId
|
|
||||||
),
|
|
||||||
eq(resourcePolicyWhiteList.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"Email not found in whitelist"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(resourcePolicyWhiteList)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
resourcePolicyWhiteList.resourcePolicyId,
|
|
||||||
policyId
|
|
||||||
),
|
|
||||||
eq(resourcePolicyWhiteList.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (!resource.emailWhitelistEnabled) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if email exists in whitelist
|
|
||||||
const existingEntry = await db
|
|
||||||
.select()
|
|
||||||
.from(resourceWhitelist)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceWhitelist.resourceId, resourceId),
|
|
||||||
eq(resourceWhitelist.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"Email not found in whitelist"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(resourceWhitelist)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceWhitelist.resourceId, resourceId),
|
|
||||||
eq(resourceWhitelist.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if email exists in whitelist
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Email not found in whitelist"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -109,14 +109,13 @@ export async function setResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A shared policy takes precedence over the resource's inline
|
const isInlinePolicy =
|
||||||
// (default) policy, which takes precedence over the resource's own
|
resource.resourcePolicyId === null &&
|
||||||
// direct whitelist fields. This mirrors the precedence used at
|
resource.defaultResourcePolicyId !== null;
|
||||||
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
|
|
||||||
const policyId =
|
if (isInlinePolicy) {
|
||||||
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
|
||||||
if (policyId !== null) {
|
|
||||||
const [policy] = await db
|
const [policy] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourcePolicies)
|
.from(resourcePolicies)
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ export async function listSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
@@ -286,7 +291,7 @@ export async function listSites(
|
|||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
sites.siteId,
|
sites.siteId,
|
||||||
@@ -306,20 +311,24 @@ export async function listSites(
|
|||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
const queryList = [
|
const queryList = [
|
||||||
like(sql`LOWER(${sites.name})`, q),
|
like(sql`LOWER(${sites.name})`, q),
|
||||||
like(sql`LOWER(${sites.niceId})`, q),
|
like(sql`LOWER(${sites.niceId})`, q)
|
||||||
inArray(
|
|
||||||
sites.siteId,
|
|
||||||
db
|
|
||||||
.select({ id: siteLabels.siteId })
|
|
||||||
.from(siteLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
sites.siteId,
|
||||||
|
db
|
||||||
|
.select({ id: siteLabels.siteId })
|
||||||
|
.from(siteLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
conditions.push(or(...queryList)!);
|
conditions.push(or(...queryList)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,23 +366,25 @@ export async function listSites(
|
|||||||
siteId: number;
|
siteId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
labelsForSites =
|
if (isLabelFeatureEnabled) {
|
||||||
siteIds.length === 0
|
labelsForSites =
|
||||||
? []
|
siteIds.length === 0
|
||||||
: await db
|
? []
|
||||||
.select({
|
: await db
|
||||||
labelId: labels.labelId,
|
.select({
|
||||||
name: labels.name,
|
labelId: labels.labelId,
|
||||||
color: labels.color,
|
name: labels.name,
|
||||||
siteId: siteLabels.siteId
|
color: labels.color,
|
||||||
})
|
siteId: siteLabels.siteId
|
||||||
.from(labels)
|
})
|
||||||
.innerJoin(
|
.from(labels)
|
||||||
siteLabels,
|
.innerJoin(
|
||||||
eq(siteLabels.labelId, labels.labelId)
|
siteLabels,
|
||||||
)
|
eq(siteLabels.labelId, labels.labelId)
|
||||||
.where(inArray(siteLabels.siteId, siteIds))
|
)
|
||||||
.orderBy(asc(siteLabels.siteLabelId));
|
.where(inArray(siteLabels.siteId, siteIds))
|
||||||
|
.orderBy(asc(siteLabels.siteLabelId));
|
||||||
|
}
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const createSiteResourceSchema = z
|
|||||||
siteIds: z.array(z.int()).optional(),
|
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
|
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1).nullish(),
|
destination: z.string().min(1).optional(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
@@ -315,7 +320,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -339,20 +344,25 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
like(sql`LOWER(${siteResources.destination})`, q),
|
like(sql`LOWER(${siteResources.destination})`, q),
|
||||||
like(sql`LOWER(${siteResources.alias})`, q),
|
like(sql`LOWER(${siteResources.alias})`, q),
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
||||||
like(sql`LOWER(${sites.name})`, q),
|
like(sql`LOWER(${sites.name})`, q)
|
||||||
inArray(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
db
|
|
||||||
.select({ id: siteResourceLabels.siteResourceId })
|
|
||||||
.from(siteResourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteResourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
queryList.push(
|
||||||
|
inArray(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
db
|
||||||
|
.select({ id: siteResourceLabels.siteResourceId })
|
||||||
|
.from(siteResourceLabels)
|
||||||
|
.innerJoin(
|
||||||
|
labels,
|
||||||
|
eq(labels.labelId, siteResourceLabels.labelId)
|
||||||
|
)
|
||||||
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +403,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (siteResourceIdList.length > 0) {
|
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
||||||
labelsForSiteResources = await db
|
labelsForSiteResources = await db
|
||||||
.select({
|
.select({
|
||||||
labelId: labels.labelId,
|
labelId: labels.labelId,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const updateSiteResourceSchema = z
|
|||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
scheme: z.enum(["http", "https"]).nullish(),
|
scheme: z.enum(["http", "https"]).nullish(),
|
||||||
destinationPort: z.int().positive().nullish(),
|
destinationPort: z.int().positive().nullish(),
|
||||||
destination: z.string().min(1).nullish(),
|
destination: z.string().min(1).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
|
|||||||
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
|
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
|
||||||
example: "service.example.internal"
|
example: "service.example.internal"
|
||||||
}),
|
}),
|
||||||
userIds: z.array(z.string()).optional(),
|
userIds: z.array(z.string()),
|
||||||
roleIds: z.array(z.int()).optional(),
|
roleIds: z.array(z.int()),
|
||||||
clientIds: z.array(z.int()).optional(),
|
clientIds: z.array(z.int()),
|
||||||
tcpPortRangeString: portRangeStringSchema,
|
tcpPortRangeString: portRangeStringSchema,
|
||||||
udpPortRangeString: portRangeStringSchema,
|
udpPortRangeString: portRangeStringSchema,
|
||||||
disableIcmp: z.boolean().optional(),
|
disableIcmp: z.boolean().optional(),
|
||||||
@@ -167,10 +167,6 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// if neither is provided, the existing site associations are left unchanged
|
|
||||||
if (data.siteIds === undefined && data.siteId === undefined) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
(data.siteIds !== undefined && data.siteIds.length > 0) ||
|
||||||
data.siteId !== undefined
|
data.siteId !== undefined
|
||||||
@@ -267,7 +263,7 @@ export async function updateSiteResource(
|
|||||||
const { siteResourceId } = parsedParams.data;
|
const { siteResourceId } = parsedParams.data;
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
siteIds: siteIdsInput,
|
siteIds: siteIdsInput = [], // because it can change
|
||||||
siteId,
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
@@ -291,14 +287,9 @@ export async function updateSiteResource(
|
|||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
// Backward compatibility: merge deprecated siteId into siteIds array
|
// Backward compatibility: merge deprecated siteId into siteIds array
|
||||||
const siteIdsProvided =
|
const siteIds = [...siteIdsInput];
|
||||||
siteIdsInput !== undefined || siteId !== undefined;
|
if (siteId !== undefined && !siteIds.includes(siteId)) {
|
||||||
let siteIds: number[] | undefined;
|
siteIds.push(siteId);
|
||||||
if (siteIdsProvided) {
|
|
||||||
siteIds = [...(siteIdsInput ?? [])];
|
|
||||||
if (siteId !== undefined && !siteIds.includes(siteId)) {
|
|
||||||
siteIds.push(siteId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if site resource exists
|
// Check if site resource exists
|
||||||
@@ -365,22 +356,20 @@ export async function updateSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
if (siteIds !== undefined) {
|
const sitesToAssign = await db
|
||||||
const sitesToAssign = await db
|
.select()
|
||||||
.select()
|
.from(sites)
|
||||||
.from(sites)
|
.where(
|
||||||
.where(
|
and(
|
||||||
and(
|
inArray(sites.siteId, siteIds),
|
||||||
inArray(sites.siteId, siteIds),
|
eq(sites.orgId, existingSiteResource.orgId)
|
||||||
eq(sites.orgId, existingSiteResource.orgId)
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (sitesToAssign.length !== siteIds.length) {
|
if (sitesToAssign.length !== siteIds.length) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check if destination is an IP address
|
// Only check if destination is an IP address
|
||||||
@@ -474,8 +463,7 @@ export async function updateSiteResource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let updatedSiteResource: SiteResource | undefined;
|
let updatedSiteResource: SiteResource | undefined;
|
||||||
// defaults to the existing sites; only overwritten below if siteIds/siteId was provided
|
let updatedSiteIds: number[] = [];
|
||||||
let updatedSiteIds: number[] = [...existingSiteIds];
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
// Update the site resource
|
// Update the site resource
|
||||||
const sshPamSet =
|
const sshPamSet =
|
||||||
@@ -534,103 +522,81 @@ export async function updateSiteResource(
|
|||||||
|
|
||||||
//////////////////// update the associations ////////////////////
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
if (siteIds !== undefined) {
|
// delete the site - site resources associations
|
||||||
// delete the site - site resources associations
|
await trx
|
||||||
await trx
|
.delete(siteNetworks)
|
||||||
.delete(siteNetworks)
|
.where(
|
||||||
.where(
|
eq(siteNetworks.networkId, updatedSiteResource.networkId!)
|
||||||
eq(
|
);
|
||||||
siteNetworks.networkId,
|
|
||||||
updatedSiteResource.networkId!
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedSiteIds = [];
|
for (const siteId of siteIds) {
|
||||||
for (const siteId of siteIds) {
|
await trx.insert(siteNetworks).values({
|
||||||
await trx.insert(siteNetworks).values({
|
siteId: siteId,
|
||||||
siteId: siteId,
|
networkId: updatedSiteResource.networkId!
|
||||||
networkId: updatedSiteResource.networkId!
|
});
|
||||||
});
|
updatedSiteIds.push(siteId);
|
||||||
updatedSiteIds.push(siteId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (clientIds !== undefined) {
|
await trx
|
||||||
await trx
|
.delete(clientSiteResources)
|
||||||
.delete(clientSiteResources)
|
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
||||||
.where(
|
|
||||||
eq(clientSiteResources.siteResourceId, siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientIds.length > 0) {
|
if (clientIds.length > 0) {
|
||||||
await trx.insert(clientSiteResources).values(
|
await trx.insert(clientSiteResources).values(
|
||||||
clientIds.map((clientId) => ({
|
clientIds.map((clientId) => ({
|
||||||
clientId,
|
clientId,
|
||||||
siteResourceId
|
siteResourceId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userIds !== undefined) {
|
await trx
|
||||||
await trx
|
.delete(userSiteResources)
|
||||||
.delete(userSiteResources)
|
.where(eq(userSiteResources.siteResourceId, siteResourceId));
|
||||||
.where(
|
|
||||||
eq(userSiteResources.siteResourceId, siteResourceId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userIds.length > 0) {
|
if (userIds.length > 0) {
|
||||||
await trx.insert(userSiteResources).values(
|
await trx.insert(userSiteResources).values(
|
||||||
userIds.map((userId) => ({
|
userIds.map((userId) => ({
|
||||||
userId,
|
userId,
|
||||||
siteResourceId
|
siteResourceId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleIds !== undefined) {
|
// Get all admin role IDs for this org to exclude from deletion
|
||||||
// Get all admin role IDs for this org to exclude from deletion
|
const adminRoles = await trx
|
||||||
const adminRoles = await trx
|
.select()
|
||||||
.select()
|
.from(roles)
|
||||||
.from(roles)
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roles.isAdmin, true),
|
||||||
|
eq(roles.orgId, updatedSiteResource.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
|
if (adminRoleIds.length > 0) {
|
||||||
|
await trx.delete(roleSiteResources).where(
|
||||||
|
and(
|
||||||
|
eq(roleSiteResources.siteResourceId, siteResourceId),
|
||||||
|
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(roleSiteResources)
|
||||||
.where(
|
.where(
|
||||||
and(
|
eq(roleSiteResources.siteResourceId, siteResourceId)
|
||||||
eq(roles.isAdmin, true),
|
|
||||||
eq(roles.orgId, updatedSiteResource.orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
}
|
||||||
|
|
||||||
if (adminRoleIds.length > 0) {
|
if (roleIds.length > 0) {
|
||||||
await trx.delete(roleSiteResources).where(
|
await trx.insert(roleSiteResources).values(
|
||||||
and(
|
roleIds.map((roleId) => ({
|
||||||
eq(
|
roleId,
|
||||||
roleSiteResources.siteResourceId,
|
siteResourceId
|
||||||
siteResourceId
|
}))
|
||||||
),
|
);
|
||||||
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await trx
|
|
||||||
.delete(roleSiteResources)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
roleSiteResources.siteResourceId,
|
|
||||||
siteResourceId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
|
||||||
await trx.insert(roleSiteResources).values(
|
|
||||||
roleIds.map((roleId) => ({
|
|
||||||
roleId,
|
|
||||||
siteResourceId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Updated site resource ${siteResourceId}`);
|
logger.info(`Updated site resource ${siteResourceId}`);
|
||||||
|
|||||||
@@ -82,11 +82,9 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
isAdmin={isAdminOrOwner}
|
isAdmin={isAdminOrOwner}
|
||||||
views={launcherData.views}
|
views={launcherData.views}
|
||||||
defaultViewOverrides={launcherData.defaultViewOverrides}
|
|
||||||
activeViewId={launcherData.activeViewId}
|
activeViewId={launcherData.activeViewId}
|
||||||
config={launcherData.config}
|
config={launcherData.config}
|
||||||
savedConfig={launcherData.savedConfig}
|
savedConfig={launcherData.savedConfig}
|
||||||
scale={launcherData.scale}
|
|
||||||
groups={launcherData.groups}
|
groups={launcherData.groups}
|
||||||
groupsPagination={launcherData.groupsPagination}
|
groupsPagination={launcherData.groupsPagination}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
@@ -3,7 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||||
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ export default async function LabelsPage({ params, searchParams }: Props) {
|
|||||||
description={t("orgLabelsDescription")}
|
description={t("orgLabelsDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||||
|
|
||||||
<OrgLabelsTable
|
<OrgLabelsTable
|
||||||
labels={labels}
|
labels={labels}
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
@@ -15,7 +15,6 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
@@ -344,10 +343,7 @@ export default function GeneralPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.siteResourceId != null
|
row.original.siteResourceId != null
|
||||||
? getPrivateResourceSettingsHref(
|
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||||
row.original.orgId,
|
|
||||||
row.original.resourceNiceId
|
|
||||||
)
|
|
||||||
: `/${row.original.orgId}/settings/resources/public/${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 { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -324,10 +323,7 @@ export default function ConnectionLogsPage() {
|
|||||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={getPrivateResourceSettingsHref(
|
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
||||||
row.original.orgId,
|
|
||||||
row.original.resourceNiceId
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
{row.original.resourceName}
|
{row.original.resourceName}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import { logQueries } from "@app/lib/queries";
|
import { logQueries } from "@app/lib/queries";
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -396,10 +395,7 @@ export default function GeneralPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
|
||||||
? getPrivateResourceSettingsHref(
|
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
||||||
row.original.orgId,
|
|
||||||
row.original.resourceNiceId
|
|
||||||
)
|
|
||||||
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
|
||||||
}
|
}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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,297 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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 { 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;
|
|
||||||
labelPrefix?: "create" | "edit";
|
|
||||||
hideDomainPicker?: boolean;
|
|
||||||
hidePaidFeaturesAlert?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PrivateResourceHttpFields({
|
|
||||||
control,
|
|
||||||
setValue,
|
|
||||||
orgId,
|
|
||||||
watch,
|
|
||||||
disabled = false,
|
|
||||||
siteResourceId,
|
|
||||||
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");
|
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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,146 +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}
|
|
||||||
/>
|
|
||||||
</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,638 +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 { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
|
||||||
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",
|
|
||||||
tcpPortRangeString: "*",
|
|
||||||
udpPortRangeString: "*",
|
|
||||||
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>
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<PrivateResourcePortRanges
|
|
||||||
control={asAnyControl(
|
|
||||||
form.control
|
|
||||||
)}
|
|
||||||
setValue={asAnySetValue(
|
|
||||||
form.setValue
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<PrivateResourcePortRanges
|
|
||||||
control={asAnyControl(
|
|
||||||
form.control
|
|
||||||
)}
|
|
||||||
setValue={asAnySetValue(
|
|
||||||
form.setValue
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</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,
|
aliasAddress: siteResource.aliasAddress || null,
|
||||||
siteNiceIds: siteResource.siteNiceIds,
|
siteNiceIds: siteResource.siteNiceIds,
|
||||||
niceId: siteResource.niceId,
|
niceId: siteResource.niceId,
|
||||||
enabled: siteResource.enabled,
|
|
||||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||||
disableIcmp: siteResource.disableIcmp || false,
|
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 { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useActionState, useState } from "react";
|
import { useActionState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -137,21 +137,11 @@ function ProxyResourceHttpForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
const [, formAction, saveLoading] = useActionState(onSubmit, null);
|
||||||
const [headersValid, setHeadersValid] = useState(true);
|
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
|
|
||||||
if (!headersValid) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("settingsErrorUpdate"),
|
|
||||||
description: t("headersValidationError")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
@@ -328,9 +318,6 @@ function ProxyResourceHttpForm({
|
|||||||
onChange={
|
onChange={
|
||||||
field.onChange
|
field.onChange
|
||||||
}
|
}
|
||||||
onValidityChange={
|
|
||||||
setHeadersValid
|
|
||||||
}
|
|
||||||
rows={4}
|
rows={4}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -354,7 +341,7 @@ function ProxyResourceHttpForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={saveLoading}
|
loading={saveLoading}
|
||||||
disabled={saveLoading || !headersValid}
|
disabled={saveLoading}
|
||||||
form="http-settings-form"
|
form="http-settings-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("saveSettings")}
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import {
|
|||||||
SettingsSubsectionHeader,
|
SettingsSubsectionHeader,
|
||||||
SettingsSubsectionTitle
|
SettingsSubsectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
||||||
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { SitesSelector } from "@app/components/site-selector";
|
import { SitesSelector } from "@app/components/site-selector";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -34,7 +35,8 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} 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 { toast } from "@app/hooks/useToast";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
@@ -221,7 +223,6 @@ function SshServerForm({
|
|||||||
|
|
||||||
const pamMode = form.watch("pamMode");
|
const pamMode = form.watch("pamMode");
|
||||||
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
const standardDaemonLocation = form.watch("standardDaemonLocation");
|
||||||
const authDaemonPort = form.watch("authDaemonPort");
|
|
||||||
const selectedNativeSite = form.watch("selectedNativeSite");
|
const selectedNativeSite = form.watch("selectedNativeSite");
|
||||||
|
|
||||||
async function save() {
|
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 =
|
const useMultiSiteTargetForm =
|
||||||
!isNative &&
|
!isNative &&
|
||||||
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
(standardDaemonLocation !== "site" || pamMode === "passthrough");
|
||||||
@@ -383,37 +413,97 @@ function SshServerForm({
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
<SshServerSettingsFields
|
<SettingsFormCell span="full">
|
||||||
idPrefix="public-ssh-edit"
|
<div className="space-y-2">
|
||||||
pamMode={pamMode}
|
<p className="font-semibold text-sm">
|
||||||
standardDaemonLocation={
|
{t("sshServerMode")}
|
||||||
standardDaemonLocation
|
</p>
|
||||||
}
|
<Badge variant="secondary">
|
||||||
authDaemonPort={authDaemonPort}
|
{sshServerMode == "standard"
|
||||||
onPamModeChange={(value) =>
|
? t("sshServerModeStandard")
|
||||||
form.setValue("pamMode", value, {
|
: t("sshServerModePangolin")}
|
||||||
shouldValidate: true
|
</Badge>
|
||||||
})
|
</div>
|
||||||
}
|
</SettingsFormCell>
|
||||||
onStandardDaemonLocationChange={(value) =>
|
|
||||||
form.setValue(
|
<SettingsFormCell span="full">
|
||||||
"standardDaemonLocation",
|
<div className="space-y-2">
|
||||||
value,
|
<p className="font-semibold text-sm">
|
||||||
{ shouldValidate: true }
|
{t("sshAuthenticationMethod")}
|
||||||
)
|
</p>
|
||||||
}
|
<StrategySelect<"passthrough" | "push">
|
||||||
onAuthDaemonPortChange={(value) =>
|
value={pamMode}
|
||||||
form.setValue("authDaemonPort", value, {
|
options={authMethodOptions}
|
||||||
shouldValidate: true
|
onChange={(value) =>
|
||||||
})
|
form.setValue("pamMode", value, {
|
||||||
}
|
shouldValidate: true
|
||||||
authDaemonPortError={
|
})
|
||||||
form.formState.errors.authDaemonPort
|
}
|
||||||
?.message
|
cols={2}
|
||||||
}
|
/>
|
||||||
sshServerMode={sshServerMode}
|
</div>
|
||||||
serverModeDisplay="badge"
|
</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">
|
<SettingsFormCell span="full">
|
||||||
<SettingsSubsectionHeader>
|
<SettingsSubsectionHeader>
|
||||||
|
|||||||
@@ -777,8 +777,8 @@ export default function Page() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<HeaderTitle
|
<HeaderTitle
|
||||||
title={t("resourcePublicCreate")}
|
title={t("resourceCreate")}
|
||||||
description={t("resourcePublicCreateDescription")}
|
description={t("resourceCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { Check, Copy } from "lucide-react";
|
import { Check, Copy } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -8,14 +7,12 @@ type CopyToClipboardProps = {
|
|||||||
text: string;
|
text: string;
|
||||||
displayText?: string;
|
displayText?: string;
|
||||||
isLink?: boolean;
|
isLink?: boolean;
|
||||||
className?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyToClipboard = ({
|
const CopyToClipboard = ({
|
||||||
text,
|
text,
|
||||||
displayText,
|
displayText,
|
||||||
isLink,
|
isLink
|
||||||
className
|
|
||||||
}: CopyToClipboardProps) => {
|
}: CopyToClipboardProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -51,20 +48,14 @@ const CopyToClipboard = ({
|
|||||||
href={text}
|
href={text}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={cn(
|
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||||
"truncate hover:underline text-sm min-w-0 max-w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
title={text} // Shows full text on hover
|
title={text} // Shows full text on hover
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className="truncate text-sm min-w-0 max-w-full"
|
||||||
"truncate text-sm min-w-0 max-w-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,36 +2,24 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
interface HeadersInputProps {
|
interface HeadersInputProps {
|
||||||
value?: { name: string; value: string }[] | null;
|
value?: { name: string; value: string }[] | null;
|
||||||
onChange: (value: { name: string; value: string }[] | null) => void;
|
onChange: (value: { name: string; value: string }[] | null) => void;
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
className?: string;
|
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({
|
export function HeadersInput({
|
||||||
value = [],
|
value = [],
|
||||||
onChange,
|
onChange,
|
||||||
onValidityChange,
|
|
||||||
placeholder = `X-Example-Header: example-value
|
placeholder = `X-Example-Header: example-value
|
||||||
X-Another-Header: another-value`,
|
X-Another-Header: another-value`,
|
||||||
rows = 4,
|
rows = 4,
|
||||||
className
|
className
|
||||||
}: HeadersInputProps) {
|
}: HeadersInputProps) {
|
||||||
const t = useTranslations();
|
|
||||||
const [internalValue, setInternalValue] = useState("");
|
const [internalValue, setInternalValue] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const isUserEditingRef = useRef(false);
|
const isUserEditingRef = useRef(false);
|
||||||
|
|
||||||
@@ -46,56 +34,37 @@ X-Another-Header: another-value`,
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse newline-separated text into header objects, validating each line
|
// Convert newline-separated string to header objects array
|
||||||
// against the same rules enforced by the server. Returns either the
|
const convertToHeadersArray = (
|
||||||
// parsed headers or an error message describing the first invalid line.
|
|
||||||
const parseHeaders = (
|
|
||||||
newlineSeparated: string
|
newlineSeparated: string
|
||||||
):
|
): { name: string; value: string }[] | null => {
|
||||||
| { headers: { name: string; value: string }[]; error: null }
|
if (!newlineSeparated || newlineSeparated.trim() === "") return [];
|
||||||
| { headers: null; error: string } => {
|
|
||||||
if (!newlineSeparated || newlineSeparated.trim() === "") {
|
|
||||||
return { headers: [], error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = newlineSeparated
|
return newlineSeparated
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((line) => line.trim())
|
.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) {
|
return { name: normalizedName, value };
|
||||||
const colonIndex = line.indexOf(":");
|
})
|
||||||
if (colonIndex === -1) {
|
.filter((header) => header.name.length > 0); // Filter out headers with invalid names
|
||||||
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 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update internal value when external value changes
|
// Update internal value when external value changes
|
||||||
// But only if the user is not currently editing (textarea not focused)
|
// But only if the user is not currently editing (textarea not focused)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isUserEditingRef.current) {
|
if (!isUserEditingRef.current) {
|
||||||
setInternalValue(convertToNewlineSeparated(value ?? []));
|
setInternalValue(convertToNewlineSeparated(value));
|
||||||
setError(null);
|
|
||||||
onValidityChange?.(true);
|
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
|
|
||||||
@@ -106,20 +75,31 @@ X-Another-Header: another-value`,
|
|||||||
// Mark that user is actively editing
|
// Mark that user is actively editing
|
||||||
isUserEditingRef.current = true;
|
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) {
|
if (newValue.trim() === "") {
|
||||||
// Surface the error and do not touch the last known good value.
|
// Empty input is valid - represents no headers
|
||||||
// Silently dropping the update here (without telling the user)
|
onChange([]);
|
||||||
// is what previously let stale data get saved without warning.
|
} else {
|
||||||
setError(result.error);
|
// Check if all non-empty lines are properly formatted (contain ':')
|
||||||
onValidityChange?.(false);
|
const lines = newValue.split("\n");
|
||||||
return;
|
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 = () => {
|
const handleFocus = () => {
|
||||||
@@ -134,20 +114,15 @@ X-Another-Header: another-value`,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Textarea
|
||||||
<Textarea
|
ref={textareaRef}
|
||||||
ref={textareaRef}
|
value={internalValue}
|
||||||
value={internalValue}
|
onChange={handleChange}
|
||||||
onChange={handleChange}
|
onFocus={handleFocus}
|
||||||
onFocus={handleFocus}
|
onBlur={handleBlur}
|
||||||
onBlur={handleBlur}
|
placeholder={placeholder}
|
||||||
placeholder={placeholder}
|
rows={rows}
|
||||||
rows={rows}
|
className={className}
|
||||||
className={className}
|
/>
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive mt-1.5">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import { cn } from "@app/lib/cn";
|
|||||||
export function InfoSections({
|
export function InfoSections({
|
||||||
children,
|
children,
|
||||||
cols,
|
cols,
|
||||||
columnSizing = "content",
|
columnSizing = "content"
|
||||||
layout = "default"
|
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
/** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */
|
||||||
columnSizing?: "fill" | "content";
|
columnSizing?: "fill" | "content";
|
||||||
/** panel: fixed 2-column grid for narrow containers such as side panels */
|
|
||||||
layout?: "default" | "panel";
|
|
||||||
}) {
|
}) {
|
||||||
const n = cols || 1;
|
const n = cols || 1;
|
||||||
const track =
|
const track =
|
||||||
@@ -22,14 +19,9 @@ export function InfoSections({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full min-w-0 gap-4",
|
"grid w-full min-w-0 grid-cols-2 md:grid-cols-(--columns) md:space-x-16 gap-4 md:items-start",
|
||||||
layout === "panel"
|
|
||||||
? "grid-cols-2 items-start"
|
|
||||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
|
||||||
columnSizing === "content" &&
|
columnSizing === "content" &&
|
||||||
(layout === "panel"
|
"md:justify-items-start md:justify-start"
|
||||||
? "justify-items-start justify-start"
|
|
||||||
: "md:justify-items-start md:justify-start")
|
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
import { launcherQueries, orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { Checkbox } from "./ui/checkbox";
|
import { Checkbox } from "./ui/checkbox";
|
||||||
|
|
||||||
@@ -25,7 +25,6 @@ type LabelsFilterSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
isSelected: (label: LabelFilterOption) => boolean;
|
isSelected: (label: LabelFilterOption) => boolean;
|
||||||
onToggle: (label: LabelFilterOption) => void;
|
onToggle: (label: LabelFilterOption) => void;
|
||||||
selectedLabels?: LabelFilterOption[];
|
|
||||||
onClear?: () => void;
|
onClear?: () => void;
|
||||||
showClear?: boolean;
|
showClear?: boolean;
|
||||||
scope?: "org" | "launcher";
|
scope?: "org" | "launcher";
|
||||||
@@ -35,7 +34,6 @@ export function LabelsFilterSelector({
|
|||||||
orgId,
|
orgId,
|
||||||
isSelected,
|
isSelected,
|
||||||
onToggle,
|
onToggle,
|
||||||
selectedLabels = [],
|
|
||||||
onClear,
|
onClear,
|
||||||
showClear = false,
|
showClear = false,
|
||||||
scope = "org"
|
scope = "org"
|
||||||
@@ -56,7 +54,7 @@ export function LabelsFilterSelector({
|
|||||||
...launcherQueries.labels({
|
...launcherQueries.labels({
|
||||||
orgId,
|
orgId,
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
}),
|
}),
|
||||||
enabled: scope === "launcher"
|
enabled: scope === "launcher"
|
||||||
});
|
});
|
||||||
@@ -65,18 +63,6 @@ export function LabelsFilterSelector({
|
|||||||
? (launcherLabelsQuery.data ?? [])
|
? (launcherLabelsQuery.data ?? [])
|
||||||
: (orgLabelsQuery.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 (
|
return (
|
||||||
<Command shouldFilter={false}>
|
<Command shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
@@ -95,7 +81,7 @@ export function LabelsFilterSelector({
|
|||||||
{t("accessFilterClear")}
|
{t("accessFilterClear")}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
)}
|
)}
|
||||||
{labelsShown.map((label) => (
|
{labels.map((label) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={label.labelId}
|
key={label.labelId}
|
||||||
value={label.name}
|
value={label.name}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function LayoutMobileMenu({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t(
|
{t(
|
||||||
"resourceSidebarLauncherTitle"
|
"resourceLauncherTitle"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -175,46 +175,34 @@ export function LayoutSidebar({
|
|||||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSidebarCollapsed ? (
|
<Link
|
||||||
<TooltipProvider>
|
href={`/${orgId}`}
|
||||||
<Tooltip>
|
className={cn(
|
||||||
<TooltipTrigger asChild>
|
"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",
|
||||||
<Link
|
isSidebarCollapsed
|
||||||
href={`/${orgId}`}
|
? "px-2 py-2 justify-center"
|
||||||
className={cn(
|
: "px-3 py-1.5"
|
||||||
"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"
|
)}
|
||||||
)}
|
title={
|
||||||
>
|
isSidebarCollapsed
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
? t("resourceLauncherTitle")
|
||||||
<SquareMousePointer className="h-4 w-4" />
|
: undefined
|
||||||
</span>
|
}
|
||||||
</Link>
|
>
|
||||||
</TooltipTrigger>
|
<span
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
{t("resourceSidebarLauncherTitle")}
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={`/${orgId}`}
|
|
||||||
className={cn(
|
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" />
|
||||||
<SquareMousePointer className="h-4 w-4" />
|
</span>
|
||||||
</span>
|
{!isSidebarCollapsed && (
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t("resourceSidebarLauncherTitle")}
|
{t("resourceLauncherTitle")}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
)}
|
||||||
)}
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAdminPage && user.serverAdmin && (
|
{!isAdminPage && user.serverAdmin && (
|
||||||
@@ -224,44 +212,36 @@ export function LayoutSidebar({
|
|||||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isSidebarCollapsed ? (
|
<Link
|
||||||
<TooltipProvider>
|
href="/admin"
|
||||||
<Tooltip>
|
className={cn(
|
||||||
<TooltipTrigger asChild>
|
"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",
|
||||||
<Link
|
isSidebarCollapsed
|
||||||
href="/admin"
|
? "px-2 py-2 justify-center"
|
||||||
className={cn(
|
: "px-3 py-1.5"
|
||||||
"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"
|
)}
|
||||||
)}
|
title={
|
||||||
>
|
isSidebarCollapsed
|
||||||
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
? t("serverAdmin")
|
||||||
<Server className="h-4 w-4" />
|
: undefined
|
||||||
</span>
|
}
|
||||||
</Link>
|
>
|
||||||
</TooltipTrigger>
|
<span
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
sideOffset={8}
|
|
||||||
>
|
|
||||||
<p>{t("serverAdmin")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href="/admin"
|
|
||||||
className={cn(
|
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" />
|
||||||
<Server className="h-4 w-4" />
|
</span>
|
||||||
</span>
|
{!isSidebarCollapsed && (
|
||||||
<span className="flex-1">
|
<>
|
||||||
{t("serverAdmin")}
|
<span className="flex-1">
|
||||||
</span>
|
{t("serverAdmin")}
|
||||||
</Link>
|
</span>
|
||||||
)}
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarNav
|
<SidebarNav
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export default function MachineClientsTable({
|
|||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
const data = useQuery(productUpdatesQueries.latestVersion(true));
|
||||||
|
|
||||||
const latestPlatformVersions = data.data?.data;
|
const latestPlatformVersions = data.data?.data;
|
||||||
@@ -433,8 +434,11 @@ export default function MachineClientsTable({
|
|||||||
accessorKey: "subnet",
|
accessorKey: "subnet",
|
||||||
friendlyName: t("address"),
|
friendlyName: t("address"),
|
||||||
header: () => <span className="px-3">{t("address")}</span>
|
header: () => <span className="px-3">{t("address")}</span>
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
baseColumns.push({
|
||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -454,8 +458,8 @@ export default function MachineClientsTable({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
// Only include actions column if there are rows without userIds
|
// Only include actions column if there are rows without userIds
|
||||||
if (hasRowsWithoutUserId) {
|
if (hasRowsWithoutUserId) {
|
||||||
@@ -537,7 +541,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
|
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import {
|
|||||||
ControlledDataTable,
|
ControlledDataTable,
|
||||||
type ExtendedColumnDef
|
type ExtendedColumnDef
|
||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
import { LabelBadge } from "./label-badge";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
||||||
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
|
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
|
||||||
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
|
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
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 { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
import {
|
import {
|
||||||
ResourceSitesStatusCell,
|
ResourceSitesStatusCell,
|
||||||
@@ -32,14 +34,12 @@ import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
ArrowRight,
|
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
@@ -47,7 +47,6 @@ import {
|
|||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
import { startTransition, useMemo, useState, useTransition } from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
@@ -79,7 +78,6 @@ export type InternalResourceRow = {
|
|||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
enabled: boolean;
|
|
||||||
tcpPortRangeString: string | null;
|
tcpPortRangeString: string | null;
|
||||||
udpPortRangeString: string | null;
|
udpPortRangeString: string | null;
|
||||||
disableIcmp: boolean;
|
disableIcmp: boolean;
|
||||||
@@ -142,14 +140,18 @@ export default function PrivateResourcesTable({
|
|||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
|
||||||
|
|
||||||
const [selectedInternalResource, setSelectedInternalResource] =
|
const [selectedInternalResource, setSelectedInternalResource] =
|
||||||
useState<InternalResourceRow | null>();
|
useState<InternalResourceRow | null>();
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingResource, setEditingResource] =
|
||||||
|
useState<InternalResourceRow | null>();
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startRefreshTransition] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
|
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
// const interval = setInterval(() => {
|
// const interval = setInterval(() => {
|
||||||
@@ -448,17 +450,6 @@ export default function PrivateResourcesTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<Link
|
|
||||||
className="block w-full"
|
|
||||||
href={getPrivateResourceSettingsHref(
|
|
||||||
resourceRow.orgId,
|
|
||||||
resourceRow.niceId
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
{t("viewSettings")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</Link>
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedInternalResource(
|
setSelectedInternalResource(
|
||||||
@@ -473,22 +464,23 @@ export default function PrivateResourcesTable({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Link
|
<Button
|
||||||
href={getPrivateResourceSettingsHref(
|
variant={"outline"}
|
||||||
resourceRow.orgId,
|
onClick={() => {
|
||||||
resourceRow.niceId
|
setEditingResource(resourceRow);
|
||||||
)}
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button variant={"outline"}>
|
{t("edit")}
|
||||||
{t("edit")}
|
</Button>
|
||||||
<ArrowRight className="ml-2 w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
cols.splice(cols.length - 1, 0, {
|
||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -508,11 +500,11 @@ export default function PrivateResourcesTable({
|
|||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [orgId, t, searchParams]);
|
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
@@ -588,15 +580,8 @@ export default function PrivateResourcesTable({
|
|||||||
tableId="internal-resources"
|
tableId="internal-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchQuery={searchParams.get("query")?.toString()}
|
searchQuery={searchParams.get("query")?.toString()}
|
||||||
onAdd={() =>
|
onAdd={() => setIsCreateDialogOpen(true)}
|
||||||
startNavigation(() =>
|
|
||||||
router.push(
|
|
||||||
`/${orgId}/settings/resources/private/create`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
isNavigatingToAddPage={isNavigatingToAddPage}
|
|
||||||
onSearch={handleSearchChange}
|
onSearch={handleSearchChange}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
onPaginationChange={handlePaginationChange}
|
onPaginationChange={handlePaginationChange}
|
||||||
@@ -612,6 +597,34 @@ export default function PrivateResourcesTable({
|
|||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ export default function PublicResourcesTable({
|
|||||||
useState<ResourceRow | null>();
|
useState<ResourceRow | null>();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
@@ -603,8 +604,11 @@ export default function PublicResourcesTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
cols.splice(cols.length - 1, 0, {
|
||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -621,11 +625,11 @@ export default function PublicResourcesTable({
|
|||||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
||||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
||||||
)
|
)
|
||||||
}
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [orgId, t, searchParams]);
|
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
|
|||||||
@@ -1,267 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import CertificateStatus from "@app/components/CertificateStatus";
|
|
||||||
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 { build } from "@server/build";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
type SiteResourceInfoInput = {
|
|
||||||
orgId: string;
|
|
||||||
mode: PrivateResourceMode;
|
|
||||||
destination: string | null;
|
|
||||||
destinationPort: number | null;
|
|
||||||
scheme: "http" | "https" | null;
|
|
||||||
ssl: boolean;
|
|
||||||
domainId?: string | null;
|
|
||||||
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 showCertificate = !!(
|
|
||||||
siteResource.mode === "http" &&
|
|
||||||
siteResource.ssl &&
|
|
||||||
siteResource.domainId &&
|
|
||||||
siteResource.fullDomain &&
|
|
||||||
build != "oss"
|
|
||||||
);
|
|
||||||
|
|
||||||
const numSections =
|
|
||||||
2 +
|
|
||||||
(showDestination ? 1 : 0) +
|
|
||||||
(showAlias ? 1 : 0) +
|
|
||||||
(showCertificate ? 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}
|
|
||||||
|
|
||||||
{showCertificate ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("certificateStatus", {
|
|
||||||
defaultValue: "Certificate"
|
|
||||||
})}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
<CertificateStatus
|
|
||||||
orgId={siteResource.orgId}
|
|
||||||
domainId={siteResource.domainId!}
|
|
||||||
fullDomain={siteResource.fullDomain!}
|
|
||||||
autoFetch={true}
|
|
||||||
showLabel={false}
|
|
||||||
polling={true}
|
|
||||||
/>
|
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { SettingsContainer } from "@app/components/Settings";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
|
||||||
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
|
|
||||||
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
import type { ListResourcesResponse } from "@server/routers/resource";
|
import type { ListResourcesResponse } from "@server/routers/resource";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
@@ -433,13 +432,19 @@ export default function SiteResourcesOverview({
|
|||||||
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
|
editHref: `/${orgId}/settings/resources/public/${r.niceId}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const privateRows = privateList.map((row) => ({
|
const privateRows = privateList.map((row) => {
|
||||||
key: row.siteResourceId,
|
const qs = new URLSearchParams({
|
||||||
meta: <PrivateResourceMeta row={row} />,
|
siteId: String(siteId),
|
||||||
name: row.name,
|
query: row.niceId
|
||||||
access: <PrivateAccessMethod row={row} />,
|
});
|
||||||
editHref: getPrivateResourceSettingsHref(orgId, 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) {
|
if (showEmptyPlaceholder) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
|
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { LabelsTableCell } from "./LabelsTableCell";
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -113,6 +114,7 @@ export default function SitesTable({
|
|||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -169,10 +171,7 @@ export default function SitesTable({
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("siteErrorRestart"),
|
title: t("siteErrorRestart"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(e, t("siteErrorRestartDescription"))
|
||||||
e,
|
|
||||||
t("siteErrorRestartDescription")
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setRestartingSite(null);
|
setRestartingSite(null);
|
||||||
@@ -599,8 +598,11 @@ export default function SitesTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
{
|
];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
cols.splice(cols.length - 1, 0, {
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
header: () => (
|
header: () => (
|
||||||
<LabelColumnFilterButton
|
<LabelColumnFilterButton
|
||||||
@@ -616,11 +618,11 @@ export default function SitesTable({
|
|||||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||||
)
|
)
|
||||||
}
|
});
|
||||||
];
|
}
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [orgId, t, searchParams, latestNewtVersion]);
|
}, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]);
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ interface StrategySelectProps<TValue extends string> {
|
|||||||
defaultValue?: TValue;
|
defaultValue?: TValue;
|
||||||
onChange?: (value: TValue) => void;
|
onChange?: (value: TValue) => void;
|
||||||
cols?: number;
|
cols?: number;
|
||||||
idPrefix?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StrategySelect<TValue extends string>({
|
export function StrategySelect<TValue extends string>({
|
||||||
@@ -26,8 +25,7 @@ export function StrategySelect<TValue extends string>({
|
|||||||
value: controlledValue,
|
value: controlledValue,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
onChange,
|
onChange,
|
||||||
cols = 1,
|
cols = 1
|
||||||
idPrefix = "strategy"
|
|
||||||
}: StrategySelectProps<TValue>) {
|
}: StrategySelectProps<TValue>) {
|
||||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
const [uncontrolledSelected, setUncontrolledSelected] = useState<
|
||||||
TValue | undefined
|
TValue | undefined
|
||||||
@@ -51,49 +49,43 @@ export function StrategySelect<TValue extends string>({
|
|||||||
}}
|
}}
|
||||||
className="grid md:grid-cols-(--cols) gap-4"
|
className="grid md:grid-cols-(--cols) gap-4"
|
||||||
>
|
>
|
||||||
{options.map((option: StrategyOption<TValue>) => {
|
{options.map((option: StrategyOption<TValue>) => (
|
||||||
const optionId = `${idPrefix}-${option.id}`;
|
<label
|
||||||
|
key={option.id}
|
||||||
return (
|
htmlFor={option.id}
|
||||||
<label
|
data-state={
|
||||||
key={option.id}
|
selected === option.id ? "checked" : "unchecked"
|
||||||
htmlFor={optionId}
|
}
|
||||||
data-state={
|
className={cn(
|
||||||
selected === option.id ? "checked" : "unchecked"
|
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
||||||
}
|
option.disabled
|
||||||
className={cn(
|
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||||
"relative flex rounded-lg border p-4 transition-colors cursor-pointer",
|
: selected === option.id
|
||||||
option.disabled
|
? "border-primary bg-primary/10 text-primary"
|
||||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
: "border-input hover:bg-accent"
|
||||||
: 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>
|
||||||
)}
|
)}
|
||||||
>
|
<div className="flex-1">
|
||||||
<RadioGroupItem
|
<div className="font-medium">{option.title}</div>
|
||||||
value={option.id}
|
<div className="text-sm text-muted-foreground">
|
||||||
id={optionId}
|
{typeof option.description === "string"
|
||||||
disabled={option.disabled}
|
? option.description
|
||||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
: option.description}
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
);
|
</label>
|
||||||
})}
|
))}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export type MultiSitesSelectorProps = {
|
|||||||
onSelectionChange: (sites: Selectedsite[]) => void;
|
onSelectionChange: (sites: Selectedsite[]) => void;
|
||||||
filterTypes?: string[];
|
filterTypes?: string[];
|
||||||
scope?: "org" | "launcher";
|
scope?: "org" | "launcher";
|
||||||
onClear?: () => void;
|
|
||||||
showClear?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatMultiSitesSelectorLabel(
|
export function formatMultiSitesSelectorLabel(
|
||||||
@@ -44,9 +42,7 @@ export function MultiSitesSelector({
|
|||||||
selectedSites,
|
selectedSites,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
filterTypes,
|
filterTypes,
|
||||||
scope = "org",
|
scope = "org"
|
||||||
onClear,
|
|
||||||
showClear = false
|
|
||||||
}: MultiSitesSelectorProps) {
|
}: MultiSitesSelectorProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
const [siteSearchQuery, setSiteSearchQuery] = useState("");
|
||||||
@@ -64,7 +60,7 @@ export function MultiSitesSelector({
|
|||||||
...launcherQueries.sites({
|
...launcherQueries.sites({
|
||||||
orgId,
|
orgId,
|
||||||
query: debouncedQuery,
|
query: debouncedQuery,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
}),
|
}),
|
||||||
enabled: scope === "launcher"
|
enabled: scope === "launcher"
|
||||||
});
|
});
|
||||||
@@ -111,14 +107,6 @@ export function MultiSitesSelector({
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
<CommandEmpty>{t("siteNotFound")}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{showClear && onClear && (
|
|
||||||
<CommandItem
|
|
||||||
onSelect={onClear}
|
|
||||||
className="text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t("accessFilterClear")}
|
|
||||||
</CommandItem>
|
|
||||||
)}
|
|
||||||
{sitesShown.map((site) => (
|
{sitesShown.map((site) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={site.siteId}
|
key={site.siteId}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
type SelectedLabel
|
type SelectedLabel
|
||||||
} from "@app/components/labels-selector";
|
} from "@app/components/labels-selector";
|
||||||
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -47,14 +46,14 @@ export function LauncherFilterPopover({
|
|||||||
const { data: labels = [] } = useQuery(
|
const { data: labels = [] } = useQuery(
|
||||||
launcherQueries.labels({
|
launcherQueries.labels({
|
||||||
orgId,
|
orgId,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: sites = [] } = useQuery(
|
const { data: sites = [] } = useQuery(
|
||||||
launcherQueries.sites({
|
launcherQueries.sites({
|
||||||
orgId,
|
orgId,
|
||||||
perPage: 20
|
perPage: 500
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,32 +96,14 @@ export function LauncherFilterPopover({
|
|||||||
[labels, selectedLabels]
|
[labels, selectedLabels]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeFilterCount = selectedSites.length + selectedLabels.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover modal={false}>
|
<Popover modal={false}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" size="icon" className="shrink-0">
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="relative shrink-0"
|
|
||||||
>
|
|
||||||
<Funnel className="size-4" />
|
<Funnel className="size-4" />
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
{activeFilterCount > 0
|
{t("resourceLauncherFilter")}
|
||||||
? t("resourceLauncherFilterWithCount", {
|
|
||||||
count: activeFilterCount
|
|
||||||
})
|
|
||||||
: t("resourceLauncherFilter")}
|
|
||||||
</span>
|
</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>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-72">
|
<PopoverContent align="end" className="w-72">
|
||||||
@@ -158,10 +139,6 @@ export function LauncherFilterPopover({
|
|||||||
selectedSites={resolvedSelectedSites}
|
selectedSites={resolvedSelectedSites}
|
||||||
onSelectionChange={onSitesChange}
|
onSelectionChange={onSitesChange}
|
||||||
scope="launcher"
|
scope="launcher"
|
||||||
showClear={selectedSites.length > 0}
|
|
||||||
onClear={() => {
|
|
||||||
onSitesChange([]);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -195,7 +172,6 @@ export function LauncherFilterPopover({
|
|||||||
<LabelsFilterSelector
|
<LabelsFilterSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
scope="launcher"
|
scope="launcher"
|
||||||
selectedLabels={resolvedSelectedLabels}
|
|
||||||
isSelected={(label) =>
|
isSelected={(label) =>
|
||||||
selectedLabelIds.has(label.labelId)
|
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 } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
|
||||||
useInfiniteQuery({
|
useInfiniteQuery({
|
||||||
...launcherQueries.groups(orgId, groupFilters),
|
...launcherQueries.groups(orgId, groupFilters),
|
||||||
...(initialGroups.length > 0
|
initialData: {
|
||||||
? {
|
pages: [
|
||||||
initialData: {
|
{
|
||||||
pages: [
|
groups: initialGroups,
|
||||||
{
|
pagination: groupsPagination
|
||||||
groups: initialGroups,
|
}
|
||||||
pagination: groupsPagination
|
],
|
||||||
}
|
pageParams: [1]
|
||||||
],
|
},
|
||||||
pageParams: [1]
|
refetchOnMount: false
|
||||||
},
|
|
||||||
refetchOnMount: false as const
|
|
||||||
}
|
|
||||||
: {})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
const groups = data?.pages.flatMap((page) => page.groups) ?? [];
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ export function LauncherRefreshButton({
|
|||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<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>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,17 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
SidePanel,
|
SidePanel,
|
||||||
SidePanelBody,
|
SidePanelBody,
|
||||||
SidePanelContent,
|
SidePanelContent,
|
||||||
|
SidePanelDescription,
|
||||||
SidePanelFooter,
|
SidePanelFooter,
|
||||||
SidePanelHeader,
|
SidePanelHeader,
|
||||||
SidePanelTitle
|
SidePanelTitle
|
||||||
} from "@app/components/SidePanel";
|
} from "@app/components/SidePanel";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
|
||||||
derivePublicAuthState,
|
|
||||||
formatPublicResourceType
|
|
||||||
} from "@app/lib/launcherResourceDetails";
|
|
||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
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 { 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 { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -58,368 +23,6 @@ type LauncherResourcePanelProps = {
|
|||||||
isAdmin: boolean;
|
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({
|
export function LauncherResourcePanel({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -434,16 +37,11 @@ export function LauncherResourcePanel({
|
|||||||
<SidePanelContent>
|
<SidePanelContent>
|
||||||
<SidePanelHeader>
|
<SidePanelHeader>
|
||||||
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
|
||||||
|
<SidePanelDescription>
|
||||||
|
{t("resourceLauncherResourceDetailsDescription")}
|
||||||
|
</SidePanelDescription>
|
||||||
</SidePanelHeader>
|
</SidePanelHeader>
|
||||||
<SidePanelBody>
|
<SidePanelBody />
|
||||||
{resource ? (
|
|
||||||
<LauncherResourcePanelBody
|
|
||||||
orgId={orgId}
|
|
||||||
resource={resource}
|
|
||||||
open={open}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SidePanelBody>
|
|
||||||
<SidePanelFooter>
|
<SidePanelFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user