mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-08 15:14:52 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92775a51ca | |||
| ea60aa701e | |||
| dbb1e7b274 | |||
| b2d1a66279 | |||
| 202466792c | |||
| 4dcf684868 | |||
| 2b8f50af6f | |||
| a8c255ef71 | |||
| d9608bd408 | |||
| be193731c1 | |||
| 0c3edc7560 | |||
| 813a3f07dc | |||
| 417438c209 | |||
| beaa5735ce | |||
| 4c8bd4db0a | |||
| d742300e82 | |||
| a521db91a2 |
+20
-1
@@ -266,6 +266,8 @@
|
|||||||
"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",
|
||||||
@@ -613,7 +615,8 @@
|
|||||||
"idpNameInternal": "Internal",
|
"idpNameInternal": "Internal",
|
||||||
"emailInvalid": "Invalid email address",
|
"emailInvalid": "Invalid email address",
|
||||||
"inviteValidityDuration": "Please select a duration",
|
"inviteValidityDuration": "Please select a duration",
|
||||||
"accessRoleSelectPlease": "Please select a role",
|
"accessRoleSelectPlease": "A user must belong to at least one role.",
|
||||||
|
"accessRoleRequired": "Role required",
|
||||||
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
||||||
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
||||||
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
||||||
@@ -1431,6 +1434,15 @@
|
|||||||
"actionSetResourcePincode": "Set Resource Pincode",
|
"actionSetResourcePincode": "Set Resource Pincode",
|
||||||
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
||||||
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
||||||
|
"actionGetResourcePolicy": "Get Resource Policy",
|
||||||
|
"actionUpdateResourcePolicy": "Update Resource Policy",
|
||||||
|
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
||||||
|
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
||||||
|
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
||||||
|
"actionSetResourcePolicyPincode": "Set Resource Policy Pincode",
|
||||||
|
"actionSetResourcePolicyHeaderAuth": "Set Resource Policy Header Authentication",
|
||||||
|
"actionSetResourcePolicyWhitelist": "Set Resource Policy Email Whitelist",
|
||||||
|
"actionSetResourcePolicyRules": "Set Resource Policy Rules",
|
||||||
"actionCreateTarget": "Create Target",
|
"actionCreateTarget": "Create Target",
|
||||||
"actionDeleteTarget": "Delete Target",
|
"actionDeleteTarget": "Delete Target",
|
||||||
"actionGetTarget": "Get Target",
|
"actionGetTarget": "Get Target",
|
||||||
@@ -2317,6 +2329,12 @@
|
|||||||
"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",
|
||||||
@@ -3577,6 +3595,7 @@
|
|||||||
"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 all available resources and launch them from one central hub",
|
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
||||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
||||||
"resourceLauncherDefaultView": "Default",
|
"resourceLauncherDefaultView": "Default",
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ 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",
|
||||||
@@ -31,7 +30,6 @@ 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"],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.19.0";
|
export const APP_VERSION = "1.20.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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";
|
||||||
@@ -810,59 +809,6 @@ 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,
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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";
|
|
||||||
@@ -304,11 +304,6 @@ 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(
|
||||||
@@ -341,7 +336,7 @@ export async function listClients(
|
|||||||
conditions.push(or(...filterAggregates));
|
conditions.push(or(...filterAggregates));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
clients.clientId,
|
clients.clientId,
|
||||||
@@ -361,11 +356,7 @@ 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),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
clients.clientId,
|
clients.clientId,
|
||||||
db
|
db
|
||||||
@@ -377,8 +368,7 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -414,7 +404,7 @@ export async function listClients(
|
|||||||
clientId: number;
|
clientId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
if (clientIds.length > 0) {
|
||||||
labelsForClients = await db
|
labelsForClients = await db
|
||||||
.select({
|
.select({
|
||||||
labelId: labels.labelId,
|
labelId: labels.labelId,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ 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();
|
||||||
@@ -1335,6 +1336,48 @@ 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);
|
||||||
|
|||||||
@@ -895,12 +895,6 @@ authenticated.delete(
|
|||||||
user.removeUserOrg
|
user.removeUserOrg
|
||||||
);
|
);
|
||||||
|
|
||||||
// authenticated.put(
|
|
||||||
// "/newt",
|
|
||||||
// verifyApiKeyHasAction(ActionsEnum.createNewt),
|
|
||||||
// newt.createNewt
|
|
||||||
// );
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
`/org/:orgId/api-keys`,
|
`/org/:orgId/api-keys`,
|
||||||
verifyApiKeyIsRoot,
|
verifyApiKeyIsRoot,
|
||||||
|
|||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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,15 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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,15 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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,6 @@
|
|||||||
|
export * from "./listOrgLabels";
|
||||||
|
export * from "./createOrgLabel";
|
||||||
|
export * from "./updateOrgLabel";
|
||||||
|
export * from "./attachLabelToItem";
|
||||||
|
export * from "./detachLabelFromItem";
|
||||||
|
export * from "./deleteOrgLabel";
|
||||||
-13
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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";
|
||||||
@@ -274,10 +274,7 @@ function combineOrConditions(
|
|||||||
return or(...parts);
|
return or(...parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchConditionForPublic(
|
function buildSearchConditionForPublic(query: string) {
|
||||||
query: string,
|
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
) {
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -295,32 +292,21 @@ function buildSearchConditionForPublic(
|
|||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
.leftJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
|
||||||
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
|
||||||
)
|
),
|
||||||
];
|
|
||||||
|
|
||||||
if (labelsFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
db
|
db
|
||||||
.select({ id: resourceLabels.resourceId })
|
.select({ id: resourceLabels.resourceId })
|
||||||
.from(resourceLabels)
|
.from(resourceLabels)
|
||||||
.innerJoin(
|
.innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
|
||||||
labels,
|
|
||||||
eq(labels.labelId, resourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSearchConditionForSiteResource(
|
function buildSearchConditionForSiteResource(query: string) {
|
||||||
query: string,
|
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
) {
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -335,11 +321,7 @@ function buildSearchConditionForSiteResource(
|
|||||||
like(sql`LOWER(${siteResources.scheme})`, 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),
|
||||||
];
|
|
||||||
|
|
||||||
if (labelsFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
db
|
db
|
||||||
@@ -351,8 +333,7 @@ function buildSearchConditionForSiteResource(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, pattern))
|
.where(like(sql`LOWER(${labels.name})`, pattern))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
return or(...queryList);
|
return or(...queryList);
|
||||||
}
|
}
|
||||||
@@ -360,15 +341,14 @@ function buildSearchConditionForSiteResource(
|
|||||||
async function filterPublicResourceIdsByTextSearch(
|
async function filterPublicResourceIdsByTextSearch(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceIds: number[],
|
resourceIds: number[],
|
||||||
query: string,
|
query: string
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (!query.trim() || resourceIds.length === 0) {
|
if (!query.trim() || resourceIds.length === 0) {
|
||||||
return resourceIds;
|
return resourceIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
const textMatch = combineOrConditions(
|
||||||
buildSearchConditionForPublic(query, labelsFeatureEnabled),
|
buildSearchConditionForPublic(query),
|
||||||
buildSiteNameSearchCondition(query)
|
buildSiteNameSearchCondition(query)
|
||||||
);
|
);
|
||||||
if (!textMatch) {
|
if (!textMatch) {
|
||||||
@@ -395,15 +375,14 @@ async function filterPublicResourceIdsByTextSearch(
|
|||||||
async function filterSiteResourceIdsByTextSearch(
|
async function filterSiteResourceIdsByTextSearch(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
siteResourceIds: number[],
|
siteResourceIds: number[],
|
||||||
query: string,
|
query: string
|
||||||
labelsFeatureEnabled: boolean
|
|
||||||
): Promise<number[]> {
|
): Promise<number[]> {
|
||||||
if (!query.trim() || siteResourceIds.length === 0) {
|
if (!query.trim() || siteResourceIds.length === 0) {
|
||||||
return siteResourceIds;
|
return siteResourceIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textMatch = combineOrConditions(
|
const textMatch = combineOrConditions(
|
||||||
buildSearchConditionForSiteResource(query, labelsFeatureEnabled),
|
buildSearchConditionForSiteResource(query),
|
||||||
buildSiteNameSearchCondition(query)
|
buildSiteNameSearchCondition(query)
|
||||||
);
|
);
|
||||||
if (!textMatch) {
|
if (!textMatch) {
|
||||||
@@ -430,10 +409,6 @@ async function filterSiteResourceIdsByTextSearch(
|
|||||||
return rows.map((row) => row.siteResourceId);
|
return rows.map((row) => row.siteResourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function labelsEnabled(orgId: string): Promise<boolean> {
|
|
||||||
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLabelsForResources(
|
async function fetchLabelsForResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceIds: number[],
|
resourceIds: number[],
|
||||||
@@ -445,10 +420,6 @@ 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([])
|
||||||
@@ -524,15 +495,8 @@ 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 labelsFeatureEnabled = await labelsEnabled(orgId);
|
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||||
const searchPublic = buildSearchConditionForPublic(
|
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||||
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) {
|
||||||
@@ -775,10 +739,6 @@ 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);
|
||||||
|
|
||||||
@@ -788,7 +748,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, true);
|
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
publicConditions.push(searchPublic);
|
publicConditions.push(searchPublic);
|
||||||
}
|
}
|
||||||
@@ -852,10 +812,7 @@ async function listLabelGroups(
|
|||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true)
|
||||||
];
|
];
|
||||||
const searchSite = buildSearchConditionForSiteResource(
|
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||||
query.query,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
siteConditions.push(searchSite);
|
siteConditions.push(searchSite);
|
||||||
}
|
}
|
||||||
@@ -1302,23 +1259,19 @@ async function listLauncherResourcesForUserUncached(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelsFeatureEnabled = await labelsEnabled(orgId);
|
|
||||||
|
|
||||||
if (query.query.trim()) {
|
if (query.query.trim()) {
|
||||||
if (filteredResourceIds.length > 0) {
|
if (filteredResourceIds.length > 0) {
|
||||||
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
|
||||||
orgId,
|
orgId,
|
||||||
filteredResourceIds,
|
filteredResourceIds,
|
||||||
query.query,
|
query.query
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (filteredSiteResourceIds.length > 0) {
|
if (filteredSiteResourceIds.length > 0) {
|
||||||
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
|
||||||
orgId,
|
orgId,
|
||||||
filteredSiteResourceIds,
|
filteredSiteResourceIds,
|
||||||
query.query,
|
query.query
|
||||||
labelsFeatureEnabled
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1518,10 +1471,6 @@ 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),
|
||||||
|
|||||||
@@ -363,11 +363,6 @@ 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;
|
||||||
@@ -381,7 +376,6 @@ export async function getUserResources(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -397,9 +391,7 @@ export async function getUserResources(
|
|||||||
resourceLabels,
|
resourceLabels,
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId)),
|
.orderBy(asc(resourceLabels.resourceLabelId)),
|
||||||
siteResourceIdList.length === 0
|
siteResourceIdList.length === 0
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
@@ -423,7 +415,6 @@ export async function getUserResources(
|
|||||||
)
|
)
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
.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,11 +484,6 @@ 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
|
||||||
@@ -676,7 +671,7 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
@@ -697,11 +692,7 @@ 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),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
db
|
db
|
||||||
@@ -713,8 +704,7 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -747,7 +737,6 @@ export async function listResources(
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForResources =
|
labelsForResources =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -763,11 +752,8 @@ export async function listResources(
|
|||||||
resourceLabels,
|
resourceLabels,
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(inArray(resourceLabels.resourceId, resourceIdList))
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
.orderBy(asc(resourceLabels.resourceLabelId));
|
||||||
}
|
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
|
|||||||
@@ -248,11 +248,6 @@ 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),
|
||||||
@@ -262,7 +257,7 @@ export async function listUserResourceAliases(
|
|||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -310,7 +305,7 @@ export async function listUserResourceAliases(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
if (siteResourceIdList.length > 0) {
|
||||||
labelsForSiteResources = await db
|
labelsForSiteResources = await db
|
||||||
.select({
|
.select({
|
||||||
name: labels.name,
|
name: labels.name,
|
||||||
|
|||||||
@@ -235,11 +235,6 @@ export async function listSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
@@ -291,7 +286,7 @@ export async function listSites(
|
|||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
sites.siteId,
|
sites.siteId,
|
||||||
@@ -311,11 +306,7 @@ 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),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
sites.siteId,
|
sites.siteId,
|
||||||
db
|
db
|
||||||
@@ -327,8 +318,8 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
conditions.push(or(...queryList)!);
|
conditions.push(or(...queryList)!);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +357,6 @@ export async function listSites(
|
|||||||
siteId: number;
|
siteId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForSites =
|
labelsForSites =
|
||||||
siteIds.length === 0
|
siteIds.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -384,7 +374,6 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
.where(inArray(siteLabels.siteId, siteIds))
|
.where(inArray(siteLabels.siteId, siteIds))
|
||||||
.orderBy(asc(siteLabels.siteLabelId));
|
.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).optional(),
|
destination: z.string().min(1).nullish(),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -161,7 +161,8 @@ const createSiteResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
data.destination !== undefined && data.destination.trim() !== ""
|
data.destination !== undefined &&
|
||||||
|
data.destination?.trim() !== ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -286,11 +286,6 @@ 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) {
|
||||||
@@ -320,7 +315,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
@@ -344,11 +339,7 @@ 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),
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
inArray(
|
||||||
siteResources.siteResourceId,
|
siteResources.siteResourceId,
|
||||||
db
|
db
|
||||||
@@ -360,8 +351,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
)
|
)
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
.where(like(sql`LOWER(${labels.name})`, q))
|
||||||
)
|
)
|
||||||
);
|
];
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
conditions.push(or(...queryList));
|
||||||
}
|
}
|
||||||
@@ -403,7 +393,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
if (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).optional(),
|
destination: z.string().min(1).nullish(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
@@ -157,7 +157,8 @@ const updateSiteResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
data.destination !== undefined && data.destination.trim() !== ""
|
data.destination !== undefined &&
|
||||||
|
data.destination?.trim() !== ""
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -616,10 +617,7 @@ export async function updateSiteResource(
|
|||||||
await trx
|
await trx
|
||||||
.delete(roleSiteResources)
|
.delete(roleSiteResources)
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(roleSiteResources.siteResourceId, siteResourceId)
|
||||||
roleSiteResources.siteResourceId,
|
|
||||||
siteResourceId
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import m17 from "./scriptsPg/1.18.0";
|
|||||||
import m18 from "./scriptsPg/1.18.3";
|
import m18 from "./scriptsPg/1.18.3";
|
||||||
import m19 from "./scriptsPg/1.18.4";
|
import m19 from "./scriptsPg/1.18.4";
|
||||||
import m20 from "./scriptsPg/1.19.0";
|
import m20 from "./scriptsPg/1.19.0";
|
||||||
|
import m21 from "./scriptsPg/1.20.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -51,7 +52,8 @@ const migrations = [
|
|||||||
{ version: "1.18.0", run: m17 },
|
{ version: "1.18.0", run: m17 },
|
||||||
{ version: "1.18.3", run: m18 },
|
{ version: "1.18.3", run: m18 },
|
||||||
{ version: "1.18.4", run: m19 },
|
{ version: "1.18.4", run: m19 },
|
||||||
{ version: "1.19.0", run: m20 }
|
{ version: "1.19.0", run: m20 },
|
||||||
|
{ version: "1.20.0", run: m21 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import m39 from "./scriptsSqlite/1.18.3";
|
|||||||
import m40 from "./scriptsSqlite/1.18.4";
|
import m40 from "./scriptsSqlite/1.18.4";
|
||||||
import m41 from "./scriptsSqlite/1.19.0";
|
import m41 from "./scriptsSqlite/1.19.0";
|
||||||
import m42 from "./scriptsSqlite/1.19.1";
|
import m42 from "./scriptsSqlite/1.19.1";
|
||||||
import m43 from "./scriptsSqlite/1.19.5";
|
import m43 from "./scriptsSqlite/1.20.0";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -89,7 +89,7 @@ const migrations = [
|
|||||||
{ version: "1.18.4", run: m40 },
|
{ version: "1.18.4", run: m40 },
|
||||||
{ version: "1.19.0", run: m41 },
|
{ version: "1.19.0", run: m41 },
|
||||||
{ version: "1.19.1", run: m42 },
|
{ version: "1.19.1", run: m42 },
|
||||||
{ version: "1.19.5", run: m43 }
|
{ version: "1.20.0", run: m43 }
|
||||||
// Add new migrations here as they are created
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { db } from "@server/db/pg/driver";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
const version = "1.20.0";
|
||||||
|
|
||||||
|
export default async function migration() {
|
||||||
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "remoteExitNodePreferenceLabels" (
|
||||||
|
"remoteExitNodePreferenceLabelId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"remoteExitNodeId" varchar NOT NULL,
|
||||||
|
"labelId" integer NOT NULL,
|
||||||
|
CONSTRAINT "remote_exit_node_preference_label_uniq" UNIQUE("remoteExitNodeId","labelId")
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "remoteExitNodeResources" (
|
||||||
|
"remoteExitNodeResourceId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"remoteExitNodeId" varchar NOT NULL,
|
||||||
|
"destination" varchar NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "launcherViews" (
|
||||||
|
"viewId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"orgId" varchar NOT NULL,
|
||||||
|
"userId" varchar,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
"config" text NOT NULL,
|
||||||
|
"isDefault" boolean DEFAULT false NOT NULL,
|
||||||
|
"createdAt" varchar NOT NULL,
|
||||||
|
"updatedAt" varchar NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "domains" ADD COLUMN "lastCheckedAt" integer;`
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_labelId_labels_labelId_fk" FOREIGN KEY ("labelId") REFERENCES "public"."labels"("labelId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "remoteExitNodeResources" ADD CONSTRAINT "remoteExitNodeResources_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_clients_orgid_niceid" ON "clients" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_networks_orgid" ON "networks" USING btree ("orgId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resourcepolicies_orgid_niceid" ON "resourcePolicies" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resources_niceid" ON "resources" USING btree ("niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_resources_orgid_niceid" ON "resources" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_siteresources_orgid_niceid" ON "siteResources" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE INDEX "idx_sites_orgid_niceid" ON "sites" USING btree ("orgId","niceId");
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`COMMIT`);
|
||||||
|
console.log("Migrated database");
|
||||||
|
} catch (e) {
|
||||||
|
await db.execute(sql`ROLLBACK`);
|
||||||
|
console.log("Unable to migrate database");
|
||||||
|
console.log(e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`${version} migration complete`);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { APP_PATH } from "@server/lib/consts";
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const version = "1.19.5";
|
const version = "1.20.0";
|
||||||
|
|
||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log(`Running setup script ${version}...`);
|
console.log(`Running setup script ${version}...`);
|
||||||
@@ -92,6 +92,64 @@ export default async function migration() {
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';`
|
`ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';`
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'remoteExitNodePreferenceLabels' (
|
||||||
|
'remoteExitNodePreferenceLabelId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'remoteExitNodeId' text NOT NULL,
|
||||||
|
'labelId' integer NOT NULL,
|
||||||
|
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('labelId') REFERENCES 'labels'('labelId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE UNIQUE INDEX 'remote_exit_node_preference_label_uniq' ON 'remoteExitNodePreferenceLabels' ('remoteExitNodeId','labelId');
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'remoteExitNodeResources' (
|
||||||
|
'remoteExitNodeResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'remoteExitNodeId' text NOT NULL,
|
||||||
|
'destination' text NOT NULL,
|
||||||
|
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'launcherViews' (
|
||||||
|
'viewId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'orgId' text NOT NULL,
|
||||||
|
'userId' text,
|
||||||
|
'name' text NOT NULL,
|
||||||
|
'config' text NOT NULL,
|
||||||
|
'isDefault' integer DEFAULT false NOT NULL,
|
||||||
|
'createdAt' text NOT NULL,
|
||||||
|
'updatedAt' text NOT NULL,
|
||||||
|
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'domains' ADD 'customCertResolver' text;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'domains' ADD 'lastCheckedAt' integer;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
|
|||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("accessRoleErrorAdd"),
|
title: t("accessRoleRequired"),
|
||||||
description: t("accessRoleSelectPlease")
|
description: t("accessRoleSelectPlease")
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
|
|||||||
if (values.roles.length === 0) {
|
if (values.roles.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("accessRoleErrorAdd"),
|
title: t("accessRoleRequired"),
|
||||||
description: t("accessRoleSelectPlease")
|
description: t("accessRoleSelectPlease")
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const willHaveAdminRole = values.roles.some(
|
const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
|
||||||
(r) => r.isAdmin === true
|
|
||||||
);
|
|
||||||
|
|
||||||
const isRemovingOwnAdmin =
|
const isRemovingOwnAdmin =
|
||||||
sessionUser.userId === user.userId &&
|
sessionUser.userId === user.userId &&
|
||||||
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void handleAccessControlsSubmit(e)}
|
onSubmit={(e) =>
|
||||||
|
void handleAccessControlsSubmit(e)
|
||||||
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="access-controls-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ export default function GeneralPage() {
|
|||||||
if (agent in latestPlatformVersions) {
|
if (agent in latestPlatformVersions) {
|
||||||
const agentVersion = latestPlatformVersions[agent];
|
const agentVersion = latestPlatformVersions[agent];
|
||||||
|
|
||||||
updateAvailable = semver.lt(
|
updateAvailable = Boolean(
|
||||||
client.olmVersion,
|
semver.valid(client.olmVersion) &&
|
||||||
agentVersion.latestVersion
|
semver.lt(client.olmVersion, agentVersion.latestVersion)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-4
@@ -3,9 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -51,8 +49,6 @@ 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,6 +15,7 @@ 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";
|
||||||
@@ -343,7 +344,10 @@ export default function GeneralPage() {
|
|||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
row.original.siteResourceId != null
|
row.original.siteResourceId != null
|
||||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
? getPrivateResourceSettingsHref(
|
||||||
|
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,6 +11,7 @@ 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";
|
||||||
@@ -323,7 +324,10 @@ export default function ConnectionLogsPage() {
|
|||||||
if (row.original.resourceName && row.original.resourceNiceId) {
|
if (row.original.resourceName && row.original.resourceNiceId) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`}
|
href={getPrivateResourceSettingsHref(
|
||||||
|
row.original.orgId,
|
||||||
|
row.original.resourceNiceId
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
{row.original.resourceName}
|
{row.original.resourceName}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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";
|
||||||
@@ -395,7 +396,10 @@ 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
|
||||||
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}`
|
? getPrivateResourceSettingsHref(
|
||||||
|
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()}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"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>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
"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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
"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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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,6 +122,7 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"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 };
|
||||||
|
}
|
||||||
@@ -14,14 +14,13 @@ import {
|
|||||||
SettingsSubsectionHeader,
|
SettingsSubsectionHeader,
|
||||||
SettingsSubsectionTitle
|
SettingsSubsectionTitle
|
||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { StrategySelect, StrategyOption } from "@app/components/StrategySelect";
|
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||||
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,
|
||||||
@@ -35,8 +34,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
import { ChevronsUpDown, ExternalLink } from "lucide-react";
|
import { ChevronsUpDown } 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";
|
||||||
@@ -223,6 +221,7 @@ 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() {
|
||||||
@@ -364,35 +363,6 @@ 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");
|
||||||
@@ -413,97 +383,37 @@ function SshServerForm({
|
|||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<SettingsSectionForm variant="half">
|
<SettingsSectionForm variant="half">
|
||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
<SettingsFormCell span="full">
|
<SshServerSettingsFields
|
||||||
<div className="space-y-2">
|
idPrefix="public-ssh-edit"
|
||||||
<p className="font-semibold text-sm">
|
pamMode={pamMode}
|
||||||
{t("sshServerMode")}
|
standardDaemonLocation={
|
||||||
</p>
|
standardDaemonLocation
|
||||||
<Badge variant="secondary">
|
}
|
||||||
{sshServerMode == "standard"
|
authDaemonPort={authDaemonPort}
|
||||||
? t("sshServerModeStandard")
|
onPamModeChange={(value) =>
|
||||||
: t("sshServerModePangolin")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</SettingsFormCell>
|
|
||||||
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="font-semibold text-sm">
|
|
||||||
{t("sshAuthenticationMethod")}
|
|
||||||
</p>
|
|
||||||
<StrategySelect<"passthrough" | "push">
|
|
||||||
value={pamMode}
|
|
||||||
options={authMethodOptions}
|
|
||||||
onChange={(value) =>
|
|
||||||
form.setValue("pamMode", value, {
|
form.setValue("pamMode", value, {
|
||||||
shouldValidate: true
|
shouldValidate: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
cols={2}
|
onStandardDaemonLocationChange={(value) =>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsFormCell>
|
|
||||||
|
|
||||||
{showDaemonLocation && (
|
|
||||||
<SettingsFormCell span="full">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="font-semibold text-sm">
|
|
||||||
{t("sshAuthDaemonLocation")}
|
|
||||||
</p>
|
|
||||||
<StrategySelect<"site" | "remote">
|
|
||||||
value={standardDaemonLocation}
|
|
||||||
options={daemonLocationOptions}
|
|
||||||
onChange={(value) =>
|
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"standardDaemonLocation",
|
"standardDaemonLocation",
|
||||||
value,
|
value,
|
||||||
{
|
{ shouldValidate: true }
|
||||||
shouldValidate: true
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
cols={2}
|
onAuthDaemonPortChange={(value) =>
|
||||||
|
form.setValue("authDaemonPort", value, {
|
||||||
|
shouldValidate: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
authDaemonPortError={
|
||||||
|
form.formState.errors.authDaemonPort
|
||||||
|
?.message
|
||||||
|
}
|
||||||
|
sshServerMode={sshServerMode}
|
||||||
|
serverModeDisplay="badge"
|
||||||
/>
|
/>
|
||||||
<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("resourceCreate")}
|
title={t("resourcePublicCreate")}
|
||||||
description={t("resourceCreateDescription")}
|
description={t("resourcePublicCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export function CreateOrgLabelDialog({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
async function createOrgLabel(data: { name: string; color: string }) {
|
async function createOrgLabel(data: { name: string; color: string }) {
|
||||||
@@ -84,11 +83,8 @@ export function CreateOrgLabelDialog({
|
|||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
|
||||||
<OrgLabelForm
|
<OrgLabelForm
|
||||||
disabled={!canManageLabels}
|
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
if (!canManageLabels) return;
|
|
||||||
startTransition(async () => createOrgLabel(data));
|
startTransition(async () => createOrgLabel(data));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -106,7 +102,7 @@ export function CreateOrgLabelDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="org-label-form"
|
form="org-label-form"
|
||||||
disabled={isSubmitting || !canManageLabels}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("labelCreate")}
|
{t("labelCreate")}
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -44,8 +44,6 @@ export function EditOrgLabelDialog({
|
|||||||
}: EditOrgLabelDialogProps) {
|
}: EditOrgLabelDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
|
||||||
async function editOrgLabel(data: { name: string; color: string }) {
|
async function editOrgLabel(data: { name: string; color: string }) {
|
||||||
@@ -90,12 +88,9 @@ export function EditOrgLabelDialog({
|
|||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
|
||||||
<OrgLabelForm
|
<OrgLabelForm
|
||||||
disabled={!canManageLabels}
|
|
||||||
defaultValue={label}
|
defaultValue={label}
|
||||||
onSubmit={(data) => {
|
onSubmit={(data) => {
|
||||||
if (!canManageLabels) return;
|
|
||||||
startTransition(async () => editOrgLabel(data));
|
startTransition(async () => editOrgLabel(data));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -113,7 +108,7 @@ export function EditOrgLabelDialog({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
form="org-label-form"
|
form="org-label-form"
|
||||||
disabled={isSubmitting || !canManageLabels}
|
disabled={isSubmitting}
|
||||||
loading={isSubmitting}
|
loading={isSubmitting}
|
||||||
>
|
>
|
||||||
{t("labelEdit")}
|
{t("labelEdit")}
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ export function InfoSections({
|
|||||||
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: 2 columns until xl for narrow containers such as side panels */
|
/** panel: fixed 2-column grid for narrow containers such as side panels */
|
||||||
layout?: "default" | "panel";
|
layout?: "default" | "panel";
|
||||||
}) {
|
}) {
|
||||||
const n = cols || 1;
|
const n = cols || 1;
|
||||||
@@ -24,11 +24,11 @@ export function InfoSections({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"grid w-full min-w-0 gap-4",
|
"grid w-full min-w-0 gap-4",
|
||||||
layout === "panel"
|
layout === "panel"
|
||||||
? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16"
|
? "grid-cols-2 items-start"
|
||||||
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
: "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16",
|
||||||
columnSizing === "content" &&
|
columnSizing === "content" &&
|
||||||
(layout === "panel"
|
(layout === "panel"
|
||||||
? "xl:justify-items-start xl:justify-start"
|
? "justify-items-start justify-start"
|
||||||
: "md:justify-items-start md:justify-start")
|
: "md:justify-items-start md:justify-start")
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function LayoutMobileMenu({
|
|||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
{t(
|
{t(
|
||||||
"resourceLauncherTitle"
|
"resourceSidebarLauncherTitle"
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -175,34 +175,46 @@ export function LayoutSidebar({
|
|||||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isSidebarCollapsed ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href={`/${orgId}`}
|
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",
|
"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"
|
||||||
isSidebarCollapsed
|
|
||||||
? "px-2 py-2 justify-center"
|
|
||||||
: "px-3 py-1.5"
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
isSidebarCollapsed
|
|
||||||
? t("resourceLauncherTitle")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
|
||||||
!isSidebarCollapsed && "mr-3"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className="flex-shrink-0 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">
|
|
||||||
{t("resourceLauncherTitle")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{t("resourceSidebarLauncherTitle")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/${orgId}`}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md px-3 py-1.5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||||
|
<SquareMousePointer className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{t("resourceSidebarLauncherTitle")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isAdminPage && user.serverAdmin && (
|
{!isAdminPage && user.serverAdmin && (
|
||||||
@@ -212,36 +224,44 @@ export function LayoutSidebar({
|
|||||||
isSidebarCollapsed ? "mb-4" : "mb-1"
|
isSidebarCollapsed ? "mb-4" : "mb-1"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isSidebarCollapsed ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
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",
|
"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"
|
||||||
isSidebarCollapsed
|
|
||||||
? "px-2 py-2 justify-center"
|
|
||||||
: "px-3 py-1.5"
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
isSidebarCollapsed
|
|
||||||
? t("serverAdmin")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
|
|
||||||
!isSidebarCollapsed && "mr-3"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
|
||||||
|
<Server className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
sideOffset={8}
|
||||||
|
>
|
||||||
|
<p>{t("serverAdmin")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/admin"
|
||||||
|
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"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<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">
|
<span className="flex-1">
|
||||||
{t("serverAdmin")}
|
{t("serverAdmin")}
|
||||||
</span>
|
</span>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SidebarNav
|
<SidebarNav
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ 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;
|
||||||
@@ -405,9 +404,12 @@ export default function MachineClientsTable({
|
|||||||
if (agent in latestPlatformVersions) {
|
if (agent in latestPlatformVersions) {
|
||||||
const agentVersion = latestPlatformVersions[agent];
|
const agentVersion = latestPlatformVersions[agent];
|
||||||
|
|
||||||
updateAvailable = semver.lt(
|
updateAvailable = Boolean(
|
||||||
|
semver.valid(originalRow.olmVersion) &&
|
||||||
|
semver.lt(
|
||||||
originalRow.olmVersion,
|
originalRow.olmVersion,
|
||||||
agentVersion.latestVersion
|
agentVersion.latestVersion
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,11 +436,8 @@ 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: () => (
|
||||||
@@ -458,8 +457,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) {
|
||||||
@@ -541,7 +540,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [hasRowsWithoutUserId, orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ 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";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
import { useRef } from "react";
|
||||||
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
import type { FieldValues, Path, UseFormReturn } from "react-hook-form";
|
||||||
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
import { RolesSelector, type SelectedRole } from "./roles-selector";
|
||||||
|
|
||||||
@@ -41,6 +42,46 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
disabled
|
disabled
|
||||||
}: OrgRolesTagFieldProps<TFieldValues>) {
|
}: OrgRolesTagFieldProps<TFieldValues>) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
const isPopoverOpenRef = useRef(false);
|
||||||
|
const lastValidRolesRef = useRef<SelectedRole[]>(
|
||||||
|
(form.getValues(name) as SelectedRole[]) ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
function validateRolesSelection() {
|
||||||
|
const current = form.getValues(name) as SelectedRole[];
|
||||||
|
|
||||||
|
if (current.length === 0 && lastValidRolesRef.current.length > 0) {
|
||||||
|
form.setValue(name, lastValidRolesRef.current as never, {
|
||||||
|
shouldDirty: true
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("accessRoleRequired"),
|
||||||
|
description: t("accessRoleSelectPlease")
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.length > 0) {
|
||||||
|
lastValidRolesRef.current = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePopoverOpenChange(open: boolean) {
|
||||||
|
isPopoverOpenRef.current = open;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
const current = form.getValues(name) as SelectedRole[];
|
||||||
|
if (current.length > 0) {
|
||||||
|
lastValidRolesRef.current = current;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateRolesSelection();
|
||||||
|
}
|
||||||
|
|
||||||
function setRoleTags(nextValue: SelectedRole[]) {
|
function setRoleTags(nextValue: SelectedRole[]) {
|
||||||
const prev = form.getValues(name) as SelectedRole[];
|
const prev = form.getValues(name) as SelectedRole[];
|
||||||
@@ -61,30 +102,34 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (next.length === 0) {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("accessRoleErrorAdd"),
|
|
||||||
description: t("accessRoleSelectPlease")
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.setValue(name, next as never, { shouldDirty: true });
|
form.setValue(name, next as never, { shouldDirty: true });
|
||||||
|
|
||||||
|
if (next.length > 0 && !isPopoverOpenRef.current) {
|
||||||
|
lastValidRolesRef.current = next;
|
||||||
|
} else if (!isPopoverOpenRef.current) {
|
||||||
|
validateRolesSelection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={name}
|
name={name}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
|
const selectedRoles = (field.value ?? []) as SelectedRole[];
|
||||||
|
if (!isPopoverOpenRef.current && selectedRoles.length > 0) {
|
||||||
|
lastValidRolesRef.current = selectedRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>{label ?? t("roles")}</FormLabel>
|
<FormLabel>{label ?? t("roles")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<RolesSelector
|
<RolesSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
selectedRoles={field.value ?? []}
|
selectedRoles={selectedRoles}
|
||||||
onSelectRoles={setRoleTags}
|
onSelectRoles={setRoleTags}
|
||||||
|
onPopoverOpenChange={handlePopoverOpenChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -93,7 +138,8 @@ export default function OrgRolesTagField<TFieldValues extends FieldValues>({
|
|||||||
)}
|
)}
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t("actionUpdateResourceRule")]: "updateResourceRule"
|
[t("actionUpdateResourceRule")]: "updateResourceRule"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"Resource Policy": {
|
||||||
|
[t("actionGetResourcePolicy")]: "getResourcePolicy",
|
||||||
|
[t("actionUpdateResourcePolicy")]: "updateResourcePolicy",
|
||||||
|
[t("actionSetResourcePolicyUsers")]: "setResourcePolicyUsers",
|
||||||
|
[t("actionSetResourcePolicyRoles")]: "setResourcePolicyRoles",
|
||||||
|
[t("actionSetResourcePolicyPassword")]: "setResourcePolicyPassword",
|
||||||
|
[t("actionSetResourcePolicyPincode")]: "setResourcePolicyPincode",
|
||||||
|
[t("actionSetResourcePolicyHeaderAuth")]:
|
||||||
|
"setResourcePolicyHeaderAuth",
|
||||||
|
[t("actionSetResourcePolicyWhitelist")]:
|
||||||
|
"setResourcePolicyWhitelist",
|
||||||
|
[t("actionSetResourcePolicyRules")]: "setResourcePolicyRules"
|
||||||
|
},
|
||||||
|
|
||||||
Client: {
|
Client: {
|
||||||
[t("actionCreateClient")]: "createClient",
|
[t("actionCreateClient")]: "createClient",
|
||||||
[t("actionDeleteClient")]: "deleteClient",
|
[t("actionDeleteClient")]: "deleteClient",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
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,
|
||||||
@@ -34,12 +32,14 @@ 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,6 +47,7 @@ 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";
|
||||||
@@ -78,6 +79,7 @@ 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;
|
||||||
@@ -140,18 +142,14 @@ 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(() => {
|
||||||
@@ -429,6 +427,27 @@ export default function PrivateResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "labels",
|
||||||
|
accessorKey: "labels",
|
||||||
|
header: () => (
|
||||||
|
<LabelColumnFilterButton
|
||||||
|
orgId={orgId}
|
||||||
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
||||||
|
<ClientResourceLabelCell
|
||||||
|
resource={row.original}
|
||||||
|
orgId={orgId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -450,6 +469,17 @@ 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(
|
||||||
@@ -464,47 +494,25 @@ export default function PrivateResourcesTable({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button
|
<Link
|
||||||
variant={"outline"}
|
href={getPrivateResourceSettingsHref(
|
||||||
onClick={() => {
|
resourceRow.orgId,
|
||||||
setEditingResource(resourceRow);
|
resourceRow.niceId
|
||||||
setIsEditDialogOpen(true);
|
)}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
<Button variant={"outline"}>
|
||||||
{t("edit")}
|
{t("edit")}
|
||||||
|
<ArrowRight className="ml-2 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
cols.splice(cols.length - 1, 0, {
|
|
||||||
id: "labels",
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<LabelColumnFilterButton
|
|
||||||
orgId={orgId}
|
|
||||||
selectedValues={searchParams.getAll("labels")}
|
|
||||||
onSelectedValuesChange={(value) =>
|
|
||||||
handleFilterChange("labels", value)
|
|
||||||
}
|
|
||||||
label={t("labels")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
|
||||||
<ClientResourceLabelCell
|
|
||||||
resource={row.original}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
@@ -580,8 +588,15 @@ 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={() => setIsCreateDialogOpen(true)}
|
onAdd={() =>
|
||||||
|
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}
|
||||||
@@ -597,34 +612,6 @@ 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,7 +151,6 @@ 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();
|
||||||
@@ -552,6 +551,24 @@ export default function PublicResourcesTable({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "labels",
|
||||||
|
accessorKey: "labels",
|
||||||
|
header: () => (
|
||||||
|
<LabelColumnFilterButton
|
||||||
|
orgId={orgId}
|
||||||
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
||||||
|
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -607,29 +624,8 @@ export default function PublicResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
cols.splice(cols.length - 1, 0, {
|
|
||||||
id: "labels",
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<LabelColumnFilterButton
|
|
||||||
orgId={orgId}
|
|
||||||
selectedValues={searchParams.getAll("labels")}
|
|
||||||
onSelectedValuesChange={(value) =>
|
|
||||||
handleFilterChange("labels", value)
|
|
||||||
}
|
|
||||||
label={t("labels")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: ResourceRow } }) => (
|
|
||||||
<ResourceLabelCell resource={row.original} orgId={orgId} />
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [orgId, t, searchParams]);
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"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,6 +7,7 @@ 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";
|
||||||
@@ -432,19 +433,13 @@ 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) => ({
|
||||||
const qs = new URLSearchParams({
|
|
||||||
siteId: String(siteId),
|
|
||||||
query: row.niceId
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
key: row.siteResourceId,
|
key: row.siteResourceId,
|
||||||
meta: <PrivateResourceMeta row={row} />,
|
meta: <PrivateResourceMeta row={row} />,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
access: <PrivateAccessMethod row={row} />,
|
access: <PrivateAccessMethod row={row} />,
|
||||||
editHref: `/${orgId}/settings/resources/private?${qs.toString()}`
|
editHref: getPrivateResourceSettingsHref(orgId, row.niceId)
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
|
|
||||||
if (showEmptyPlaceholder) {
|
if (showEmptyPlaceholder) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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";
|
||||||
@@ -114,7 +113,6 @@ 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();
|
||||||
@@ -171,7 +169,10 @@ export default function SitesTable({
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("siteErrorRestart"),
|
title: t("siteErrorRestart"),
|
||||||
description: formatAxiosError(e, t("siteErrorRestartDescription"))
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("siteErrorRestartDescription")
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setRestartingSite(null);
|
setRestartingSite(null);
|
||||||
@@ -358,10 +359,12 @@ export default function SitesTable({
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
|
||||||
let updateAvailable =
|
let updateAvailable = Boolean(
|
||||||
latestNewtVersion &&
|
latestNewtVersion &&
|
||||||
originalRow.newtVersion &&
|
originalRow.newtVersion &&
|
||||||
semver.lt(originalRow.newtVersion, latestNewtVersion);
|
semver.valid(originalRow.newtVersion) &&
|
||||||
|
semver.lt(originalRow.newtVersion, latestNewtVersion)
|
||||||
|
);
|
||||||
|
|
||||||
if (originalRow.type === "newt") {
|
if (originalRow.type === "newt") {
|
||||||
return (
|
return (
|
||||||
@@ -497,6 +500,23 @@ export default function SitesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "labels",
|
||||||
|
header: () => (
|
||||||
|
<LabelColumnFilterButton
|
||||||
|
orgId={orgId}
|
||||||
|
selectedValues={searchParams.getAll("labels")}
|
||||||
|
onSelectedValuesChange={(value) =>
|
||||||
|
handleFilterChange("labels", value)
|
||||||
|
}
|
||||||
|
label={t("labels")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||||
|
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -601,28 +621,8 @@ export default function SitesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
cols.splice(cols.length - 1, 0, {
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<LabelColumnFilterButton
|
|
||||||
orgId={orgId}
|
|
||||||
selectedValues={searchParams.getAll("labels")}
|
|
||||||
onSelectedValuesChange={(value) =>
|
|
||||||
handleFilterChange("labels", value)
|
|
||||||
}
|
|
||||||
label={t("labels")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
|
||||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return cols;
|
return cols;
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]);
|
}, [orgId, t, searchParams, latestNewtVersion]);
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"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,6 +18,7 @@ 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>({
|
||||||
@@ -25,7 +26,8 @@ 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
|
||||||
@@ -49,10 +51,13 @@ 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}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.id}
|
key={option.id}
|
||||||
htmlFor={option.id}
|
htmlFor={optionId}
|
||||||
data-state={
|
data-state={
|
||||||
selected === option.id ? "checked" : "unchecked"
|
selected === option.id ? "checked" : "unchecked"
|
||||||
}
|
}
|
||||||
@@ -67,7 +72,7 @@ export function StrategySelect<TValue extends string>({
|
|||||||
>
|
>
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={option.id}
|
value={option.id}
|
||||||
id={option.id}
|
id={optionId}
|
||||||
disabled={option.disabled}
|
disabled={option.disabled}
|
||||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||||
/>
|
/>
|
||||||
@@ -76,7 +81,9 @@ export function StrategySelect<TValue extends string>({
|
|||||||
<div className="mt-1">{option.icon}</div>
|
<div className="mt-1">{option.icon}</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{option.title}</div>
|
<div className="font-medium">
|
||||||
|
{option.title}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{typeof option.description === "string"
|
{typeof option.description === "string"
|
||||||
? option.description
|
? option.description
|
||||||
@@ -85,7 +92,8 @@ export function StrategySelect<TValue extends string>({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -588,9 +588,12 @@ export default function UserDevicesTable({
|
|||||||
if (agent in latestPlatformVersions) {
|
if (agent in latestPlatformVersions) {
|
||||||
const agentVersion = latestPlatformVersions[agent];
|
const agentVersion = latestPlatformVersions[agent];
|
||||||
|
|
||||||
updateAvailable = semver.lt(
|
updateAvailable = Boolean(
|
||||||
|
semver.valid(originalRow.olmVersion) &&
|
||||||
|
semver.lt(
|
||||||
originalRow.olmVersion,
|
originalRow.olmVersion,
|
||||||
agentVersion.latestVersion
|
agentVersion.latestVersion
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ export interface MultiSelectInputProps<
|
|||||||
> extends MultiSelectTagsProps<T> {
|
> extends MultiSelectTagsProps<T> {
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
lockedIds?: Set<string>;
|
lockedIds?: Set<string>;
|
||||||
|
onPopoverOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultiSelectTagInput<T extends TagValue>({
|
export function MultiSelectTagInput<T extends TagValue>({
|
||||||
buttonText,
|
buttonText,
|
||||||
lockedIds,
|
lockedIds,
|
||||||
|
onPopoverOpenChange,
|
||||||
...props
|
...props
|
||||||
}: MultiSelectInputProps<T>) {
|
}: MultiSelectInputProps<T>) {
|
||||||
const selectedValues = new Set(props.value.map((v) => v.id));
|
const selectedValues = new Set(props.value.map((v) => v.id));
|
||||||
@@ -33,6 +35,7 @@ export function MultiSelectTagInput<T extends TagValue>({
|
|||||||
// clear input when popover is closed
|
// clear input when popover is closed
|
||||||
props.onSearch("");
|
props.onSearch("");
|
||||||
}
|
}
|
||||||
|
onPopoverOpenChange?.(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
InfoSections,
|
InfoSections,
|
||||||
InfoSectionTitle
|
InfoSectionTitle
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
|
import { SiteResourceInfoSections } from "@app/components/SiteResourceInfoBox";
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
@@ -26,14 +27,10 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
derivePublicAuthState,
|
derivePublicAuthState,
|
||||||
formatPortRestrictionDisplay,
|
|
||||||
formatPublicResourceType
|
formatPublicResourceType
|
||||||
} from "@app/lib/launcherResourceDetails";
|
} from "@app/lib/launcherResourceDetails";
|
||||||
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
|
||||||
import {
|
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
|
||||||
formatSiteResourceDestinationDisplay,
|
|
||||||
isSafeUrlForLink
|
|
||||||
} from "@app/lib/launcherResourceAccess";
|
|
||||||
import { launcherQueries } from "@app/lib/queries";
|
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 { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
|
||||||
@@ -322,25 +319,6 @@ function PrivateResourceDetails({
|
|||||||
resource: GetSiteResourceResponse;
|
resource: GetSiteResourceResponse;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const modeLabel: Record<GetSiteResourceResponse["mode"], string> = {
|
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
|
||||||
http: t("editInternalResourceDialogModeHttp"),
|
|
||||||
ssh: t("editInternalResourceDialogModeSsh")
|
|
||||||
};
|
|
||||||
const destination = formatSiteResourceDestinationDisplay({
|
|
||||||
mode: resource.mode,
|
|
||||||
destination: resource.destination,
|
|
||||||
destinationPort: resource.destinationPort,
|
|
||||||
scheme: resource.scheme
|
|
||||||
});
|
|
||||||
const portRestrictions = formatPortRestrictionDisplay(resource);
|
|
||||||
const showAlias = resource.mode !== "cidr" && resource.mode !== "http";
|
|
||||||
const showDestination = !(
|
|
||||||
resource.mode === "ssh" && resource.authDaemonMode === "native"
|
|
||||||
);
|
|
||||||
const infoSectionCount =
|
|
||||||
2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -375,89 +353,16 @@ function PrivateResourceDetails({
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<InfoSections cols={infoSectionCount} layout="panel">
|
<SiteResourceInfoSections
|
||||||
<InfoSection>
|
siteResource={resource}
|
||||||
<InfoSectionTitle>{t("type")}</InfoSectionTitle>
|
access={{
|
||||||
<InfoSectionContent>
|
accessDisplay: launcherResource.accessDisplay,
|
||||||
{modeLabel[resource.mode]}
|
accessCopyValue: launcherResource.accessCopyValue,
|
||||||
</InfoSectionContent>
|
accessUrl: launcherResource.accessUrl
|
||||||
</InfoSection>
|
}}
|
||||||
<InfoSection>
|
variant="panel"
|
||||||
<InfoSectionTitle>{t("access")}</InfoSectionTitle>
|
accessClassName="text-base"
|
||||||
<InfoSectionContent>
|
|
||||||
<AccessMethodContent
|
|
||||||
accessDisplay={
|
|
||||||
launcherResource.accessDisplay
|
|
||||||
}
|
|
||||||
accessCopyValue={
|
|
||||||
launcherResource.accessCopyValue
|
|
||||||
}
|
|
||||||
accessUrl={launcherResource.accessUrl}
|
|
||||||
/>
|
/>
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
{showDestination ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("editInternalResourceDialogDestination")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{destination || "-"}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
) : null}
|
|
||||||
{showAlias ? (
|
|
||||||
<InfoSection>
|
|
||||||
<InfoSectionTitle>
|
|
||||||
{t("editInternalResourceDialogAlias")}
|
|
||||||
</InfoSectionTitle>
|
|
||||||
<InfoSectionContent>
|
|
||||||
{resource.alias?.trim()
|
|
||||||
? resource.alias
|
|
||||||
: "-"}
|
|
||||||
</InfoSectionContent>
|
|
||||||
</InfoSection>
|
|
||||||
) : null}
|
|
||||||
<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>
|
|
||||||
</InfoSections>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -587,7 +587,9 @@ export default function ResourceLauncher({
|
|||||||
|
|
||||||
const renderToolbarSearch = (searchClassName: string) => (
|
const renderToolbarSearch = (searchClassName: string) => (
|
||||||
<div className={cn("relative shrink-0", searchClassName)}>
|
<div className={cn("relative shrink-0", searchClassName)}>
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center">
|
||||||
|
<Search className="size-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
<Input
|
<Input
|
||||||
key={`${activeViewId}-${searchInputResetKey}`}
|
key={`${activeViewId}-${searchInputResetKey}`}
|
||||||
defaultValue={config.query}
|
defaultValue={config.query}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type RolesSelectorProps = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
selectedRoles?: SelectedRole[];
|
selectedRoles?: SelectedRole[];
|
||||||
onSelectRoles: (roles: SelectedRole[]) => void;
|
onSelectRoles: (roles: SelectedRole[]) => void;
|
||||||
|
onPopoverOpenChange?: (open: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
restrictAdminRole?: boolean;
|
restrictAdminRole?: boolean;
|
||||||
mapRolesByName?: boolean;
|
mapRolesByName?: boolean;
|
||||||
@@ -23,6 +24,7 @@ export function RolesSelector({
|
|||||||
orgId,
|
orgId,
|
||||||
selectedRoles = [],
|
selectedRoles = [],
|
||||||
onSelectRoles,
|
onSelectRoles,
|
||||||
|
onPopoverOpenChange,
|
||||||
disabled,
|
disabled,
|
||||||
restrictAdminRole,
|
restrictAdminRole,
|
||||||
mapRolesByName,
|
mapRolesByName,
|
||||||
@@ -77,6 +79,7 @@ export function RolesSelector({
|
|||||||
options={rolesShown}
|
options={rolesShown}
|
||||||
value={selectedRoles}
|
value={selectedRoles}
|
||||||
onChange={onSelectRoles}
|
onChange={onSelectRoles}
|
||||||
|
onPopoverOpenChange={onPopoverOpenChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
lockedIds={lockedIds}
|
lockedIds={lockedIds}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export type SiteResourceAccessState = {
|
||||||
|
roleIds: number[];
|
||||||
|
userIds: string[];
|
||||||
|
clientIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteResourceContextValue = {
|
||||||
|
siteResource: SiteResourceData;
|
||||||
|
updateSiteResource: (updated: Partial<SiteResourceData>) => void;
|
||||||
|
access: SiteResourceAccessState;
|
||||||
|
setAccess: (access: SiteResourceAccessState) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SiteResourceContext = createContext<SiteResourceContextValue | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SiteResourceContext;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import SiteResourceContext from "@app/contexts/siteResourceContext";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
export function useSiteResourceContext() {
|
||||||
|
const context = useContext(SiteResourceContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useSiteResourceContext must be used within SiteResourceProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { SiteResourceData } from "@app/lib/privateResourceForm";
|
||||||
|
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
|
||||||
|
export async function fetchSiteResourceByNiceId(
|
||||||
|
orgId: string,
|
||||||
|
niceId: string
|
||||||
|
): Promise<SiteResourceData | null> {
|
||||||
|
const res = await internal.get<
|
||||||
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
|
>(
|
||||||
|
`/org/${orgId}/site-resources?query=${encodeURIComponent(niceId)}&pageSize=50`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
|
||||||
|
const match = res.data.data.siteResources.find((r) => r.niceId === niceId);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.siteResourceId,
|
||||||
|
name: match.name,
|
||||||
|
orgId,
|
||||||
|
sites: match.siteIds.map((siteId, idx) => ({
|
||||||
|
siteId,
|
||||||
|
siteName: match.siteNames[idx],
|
||||||
|
siteNiceId: match.siteNiceIds[idx],
|
||||||
|
online: match.siteOnlines[idx]
|
||||||
|
})),
|
||||||
|
mode: match.mode,
|
||||||
|
scheme: match.scheme,
|
||||||
|
ssl: match.ssl,
|
||||||
|
siteNames: match.siteNames,
|
||||||
|
siteAddresses: match.siteAddresses || null,
|
||||||
|
siteIds: match.siteIds,
|
||||||
|
destination: match.destination,
|
||||||
|
destinationPort: match.destinationPort ?? null,
|
||||||
|
alias: match.alias || null,
|
||||||
|
aliasAddress: match.aliasAddress || null,
|
||||||
|
siteNiceIds: match.siteNiceIds,
|
||||||
|
niceId: match.niceId,
|
||||||
|
tcpPortRangeString: match.tcpPortRangeString || null,
|
||||||
|
udpPortRangeString: match.udpPortRangeString || null,
|
||||||
|
disableIcmp: match.disableIcmp || false,
|
||||||
|
authDaemonMode: match.authDaemonMode ?? null,
|
||||||
|
authDaemonPort: match.authDaemonPort ?? null,
|
||||||
|
pamMode: match.pamMode ?? null,
|
||||||
|
subdomain: match.subdomain ?? null,
|
||||||
|
domainId: match.domainId ?? null,
|
||||||
|
fullDomain: match.fullDomain ?? null,
|
||||||
|
labels: match.labels ?? [],
|
||||||
|
enabled: match.enabled
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { LauncherResource } from "@server/routers/launcher/types";
|
import type { LauncherResource } from "@server/routers/launcher/types";
|
||||||
|
|
||||||
|
export function getPrivateResourceSettingsHref(
|
||||||
|
orgId: string,
|
||||||
|
niceId: string
|
||||||
|
): string {
|
||||||
|
return `/${orgId}/settings/resources/private/${niceId}/general`;
|
||||||
|
}
|
||||||
|
|
||||||
export function getLauncherResourceAdminHref(
|
export function getLauncherResourceAdminHref(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resource: LauncherResource
|
resource: LauncherResource
|
||||||
@@ -8,10 +15,5 @@ export function getLauncherResourceAdminHref(
|
|||||||
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
|
return `/${orgId}/settings/resources/public/${resource.niceId}/general`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qs = new URLSearchParams({ query: resource.niceId });
|
return getPrivateResourceSettingsHref(orgId, resource.niceId);
|
||||||
if (resource.site?.siteId != null) {
|
|
||||||
qs.set("siteId", String(resource.site.siteId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${orgId}/settings/resources/private?${qs.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,658 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
|
||||||
|
|
||||||
|
export type PrivateResourceMode = "host" | "cidr" | "http" | "ssh";
|
||||||
|
|
||||||
|
export type SiteResourceData = InternalResourceRow & {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortMode = "all" | "blocked" | "custom";
|
||||||
|
|
||||||
|
const tagSchema = z.object({ id: z.string(), text: z.string() });
|
||||||
|
|
||||||
|
export type PrivateResourceAccessTag = z.infer<typeof tagSchema>;
|
||||||
|
|
||||||
|
export type PrivateResourceClient = {
|
||||||
|
clientId: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PrivateResourceFormValues = {
|
||||||
|
name: string;
|
||||||
|
siteIds: number[];
|
||||||
|
mode: PrivateResourceMode;
|
||||||
|
destination: string | null;
|
||||||
|
alias?: string | null;
|
||||||
|
niceId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
tcpPortRangeString?: string | null;
|
||||||
|
udpPortRangeString?: string | null;
|
||||||
|
disableIcmp?: boolean;
|
||||||
|
authDaemonMode?: "site" | "remote" | "native" | null;
|
||||||
|
authDaemonPort?: number | null;
|
||||||
|
pamMode?: "passthrough" | "push" | null;
|
||||||
|
destinationPort?: number | null;
|
||||||
|
scheme?: "http" | "https";
|
||||||
|
ssl?: boolean;
|
||||||
|
httpConfigSubdomain?: string | null;
|
||||||
|
httpConfigDomainId?: string | null;
|
||||||
|
httpConfigFullDomain?: string | null;
|
||||||
|
roles?: PrivateResourceAccessTag[];
|
||||||
|
users?: PrivateResourceAccessTag[];
|
||||||
|
clients?: PrivateResourceClient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SiteResourceAccess = {
|
||||||
|
roleIds: number[];
|
||||||
|
userIds: string[];
|
||||||
|
clientIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TranslateFn = (key: string) => string;
|
||||||
|
|
||||||
|
export const isValidPortRangeString = (
|
||||||
|
val: string | undefined | null
|
||||||
|
): boolean => {
|
||||||
|
if (!val || val.trim() === "" || val.trim() === "*") return true;
|
||||||
|
const parts = val.split(",").map((p) => p.trim());
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part === "") return false;
|
||||||
|
if (part.includes("-")) {
|
||||||
|
const [start, end] = part.split("-").map((p) => p.trim());
|
||||||
|
if (!start || !end) return false;
|
||||||
|
const startPort = parseInt(start, 10);
|
||||||
|
const endPort = parseInt(end, 10);
|
||||||
|
if (isNaN(startPort) || isNaN(endPort)) return false;
|
||||||
|
if (
|
||||||
|
startPort < 1 ||
|
||||||
|
startPort > 65535 ||
|
||||||
|
endPort < 1 ||
|
||||||
|
endPort > 65535
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (startPort > endPort) return false;
|
||||||
|
} else {
|
||||||
|
const port = parseInt(part, 10);
|
||||||
|
if (isNaN(port) || port < 1 || port > 65535) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPortRangeValidationMessage = (t: TranslateFn) =>
|
||||||
|
t("editInternalResourceDialogPortRangeValidationError");
|
||||||
|
|
||||||
|
export const createPortRangeStringSchema = (t: TranslateFn) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.nullable()
|
||||||
|
.refine((val) => isValidPortRangeString(val), {
|
||||||
|
message: getPortRangeValidationMessage(t)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPortModeFromString = (
|
||||||
|
val: string | undefined | null
|
||||||
|
): PortMode => {
|
||||||
|
if (val === "*") return "all";
|
||||||
|
if (!val || val.trim() === "") return "blocked";
|
||||||
|
return "custom";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPortStringFromMode = (
|
||||||
|
mode: PortMode,
|
||||||
|
customValue: string
|
||||||
|
): string | undefined => {
|
||||||
|
if (mode === "all") return "*";
|
||||||
|
if (mode === "blocked") return "";
|
||||||
|
return customValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isHostname = (destination: string | null): boolean =>
|
||||||
|
!!destination && /[a-zA-Z]/.test(destination);
|
||||||
|
|
||||||
|
export const cleanForFQDN = (name: string): string =>
|
||||||
|
name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9.-]/g, "-")
|
||||||
|
.replace(/[-]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.replace(/^\.|\.$/g, "");
|
||||||
|
|
||||||
|
export function applyAliasAutoGeneration(
|
||||||
|
values: PrivateResourceFormValues
|
||||||
|
): PrivateResourceFormValues {
|
||||||
|
const data = { ...values };
|
||||||
|
if (
|
||||||
|
(data.mode === "host" ||
|
||||||
|
data.mode === "http" ||
|
||||||
|
(data.mode === "ssh" && data.authDaemonMode !== "native")) &&
|
||||||
|
isHostname(data.destination)
|
||||||
|
) {
|
||||||
|
const currentAlias = data.alias?.trim() || "";
|
||||||
|
if (!currentAlias) {
|
||||||
|
let aliasValue = data.destination!;
|
||||||
|
if (data.destination?.toLowerCase() === "localhost") {
|
||||||
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||||
|
}
|
||||||
|
data.alias = aliasValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accessTagsToIds(access: {
|
||||||
|
roles?: PrivateResourceAccessTag[];
|
||||||
|
users?: PrivateResourceAccessTag[];
|
||||||
|
clients?: PrivateResourceClient[];
|
||||||
|
}): SiteResourceAccess {
|
||||||
|
return {
|
||||||
|
roleIds: (access.roles ?? []).map((r) => parseInt(r.id)),
|
||||||
|
userIds: (access.users ?? []).map((u) => u.id),
|
||||||
|
clientIds: (access.clients ?? []).map((c) => c.clientId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCreateSiteResourcePayload(
|
||||||
|
values: PrivateResourceFormValues
|
||||||
|
) {
|
||||||
|
const data = applyAliasAutoGeneration(values);
|
||||||
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: data.name,
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: data.mode,
|
||||||
|
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
|
||||||
|
enabled: true,
|
||||||
|
...(data.mode === "http" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.destinationPort ?? undefined,
|
||||||
|
domainId: data.httpConfigDomainId
|
||||||
|
? data.httpConfigDomainId
|
||||||
|
: undefined,
|
||||||
|
subdomain: data.httpConfigSubdomain
|
||||||
|
? data.httpConfigSubdomain
|
||||||
|
: undefined
|
||||||
|
}),
|
||||||
|
...(data.mode === "host" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" &&
|
||||||
|
data.authDaemonPort != null && {
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...(data.mode === "ssh" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: undefined,
|
||||||
|
...(!isNativeSsh && {
|
||||||
|
destinationPort: data.destinationPort ?? undefined
|
||||||
|
}),
|
||||||
|
pamMode: data.pamMode ?? undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" &&
|
||||||
|
data.authDaemonPort != null && {
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" || data.mode === "cidr") && {
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false
|
||||||
|
}),
|
||||||
|
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
||||||
|
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||||
|
clientIds: data.clients ? data.clients.map((c) => c.clientId) : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUpdateSiteResourcePayload(
|
||||||
|
values: PrivateResourceFormValues,
|
||||||
|
access: SiteResourceAccess
|
||||||
|
) {
|
||||||
|
const data = applyAliasAutoGeneration(values);
|
||||||
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: data.name,
|
||||||
|
siteIds: data.siteIds,
|
||||||
|
mode: data.mode,
|
||||||
|
niceId: data.niceId,
|
||||||
|
enabled: data.enabled,
|
||||||
|
...(isNativeSsh
|
||||||
|
? { destination: null, destinationPort: null }
|
||||||
|
: { destination: data.destination ?? undefined }),
|
||||||
|
...(data.mode === "http" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.destinationPort ?? null,
|
||||||
|
domainId: data.httpConfigDomainId
|
||||||
|
? data.httpConfigDomainId
|
||||||
|
: undefined,
|
||||||
|
subdomain: data.httpConfigSubdomain
|
||||||
|
? data.httpConfigSubdomain
|
||||||
|
: undefined
|
||||||
|
}),
|
||||||
|
...(data.mode === "host" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: null,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" && {
|
||||||
|
authDaemonPort: data.authDaemonPort || null
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...(data.mode === "ssh" && {
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: null,
|
||||||
|
...(!isNativeSsh && {
|
||||||
|
destinationPort: data.destinationPort ?? null
|
||||||
|
}),
|
||||||
|
pamMode: data.pamMode ?? undefined,
|
||||||
|
...(data.authDaemonMode != null && {
|
||||||
|
authDaemonMode: data.authDaemonMode
|
||||||
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" && {
|
||||||
|
authDaemonPort: data.authDaemonPort || null
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
...((data.mode === "host" || data.mode === "cidr") && {
|
||||||
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
|
disableIcmp: data.disableIcmp ?? false
|
||||||
|
}),
|
||||||
|
roleIds: access.roleIds,
|
||||||
|
userIds: access.userIds,
|
||||||
|
clientIds: access.clientIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferSshPamMode(
|
||||||
|
authDaemonMode?: string | null,
|
||||||
|
pamMode?: "passthrough" | "push" | null
|
||||||
|
): "passthrough" | "push" {
|
||||||
|
if (pamMode === "passthrough" || pamMode === "push") {
|
||||||
|
return pamMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authDaemonMode === "remote" ? "push" : "passthrough";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteResourceToFormValues(
|
||||||
|
resource: SiteResourceData
|
||||||
|
): PrivateResourceFormValues {
|
||||||
|
return {
|
||||||
|
name: resource.name,
|
||||||
|
siteIds: resource.siteIds,
|
||||||
|
mode: resource.mode ?? "host",
|
||||||
|
destination: resource.destination ?? "",
|
||||||
|
alias: resource.alias ?? null,
|
||||||
|
destinationPort: resource.destinationPort ?? null,
|
||||||
|
scheme: resource.scheme ?? "http",
|
||||||
|
ssl: resource.ssl ?? false,
|
||||||
|
httpConfigSubdomain: resource.subdomain ?? null,
|
||||||
|
httpConfigDomainId: resource.domainId ?? null,
|
||||||
|
httpConfigFullDomain: resource.fullDomain ?? null,
|
||||||
|
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||||
|
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||||
|
disableIcmp: resource.disableIcmp ?? false,
|
||||||
|
authDaemonMode:
|
||||||
|
resource.authDaemonMode === "native"
|
||||||
|
? "native"
|
||||||
|
: (resource.authDaemonMode ?? "site"),
|
||||||
|
authDaemonPort: resource.authDaemonPort ?? null,
|
||||||
|
pamMode: inferSshPamMode(resource.authDaemonMode, resource.pamMode),
|
||||||
|
niceId: resource.niceId,
|
||||||
|
enabled: resource.enabled
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGeneralFormSchema(t: TranslateFn) {
|
||||||
|
return z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("editInternalResourceDialogNameRequired"))
|
||||||
|
.max(255, t("editInternalResourceDialogNameMaxLength")),
|
||||||
|
niceId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(255)
|
||||||
|
.regex(/^[a-zA-Z0-9-]+$/)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAccessFormSchema() {
|
||||||
|
return z.object({
|
||||||
|
roles: z.array(tagSchema).optional(),
|
||||||
|
users: z.array(tagSchema).optional(),
|
||||||
|
clients: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
clientId: z.number(),
|
||||||
|
name: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCreateFormSchema(t: TranslateFn) {
|
||||||
|
const destinationRequired = t(
|
||||||
|
"createInternalResourceDialogDestinationRequired"
|
||||||
|
);
|
||||||
|
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, t("createInternalResourceDialogNameRequired"))
|
||||||
|
.max(255, t("createInternalResourceDialogNameMaxLength")),
|
||||||
|
siteIds: z
|
||||||
|
.array(z.number().int().positive())
|
||||||
|
.min(1, t("createInternalResourceDialogPleaseSelectSite")),
|
||||||
|
mode: z.enum(["host", "cidr", "http", "ssh"]),
|
||||||
|
destination: z.string().nullish(),
|
||||||
|
alias: z.string().nullish(),
|
||||||
|
destinationPort: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
|
httpConfigSubdomain: z.string().nullish(),
|
||||||
|
httpConfigDomainId: z.string().nullish(),
|
||||||
|
httpConfigFullDomain: z.string().nullish(),
|
||||||
|
authDaemonMode: z
|
||||||
|
.enum(["site", "remote", "native"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
standardDaemonLocation: z
|
||||||
|
.enum(["site", "remote"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
authDaemonPort: z.number().int().positive().optional().nullable(),
|
||||||
|
pamMode: z.enum(["passthrough", "push"]).optional().nullable(),
|
||||||
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
udpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const isNativeSsh =
|
||||||
|
data.mode === "ssh" && data.authDaemonMode === "native";
|
||||||
|
const trimmedDestination = data.destination?.trim();
|
||||||
|
if (
|
||||||
|
data.mode !== "ssh" &&
|
||||||
|
(!trimmedDestination || trimmedDestination.length < 1)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: destinationRequired,
|
||||||
|
path: ["destination"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.mode === "ssh" && !isNativeSsh) {
|
||||||
|
if (!trimmedDestination || trimmedDestination.length < 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: destinationRequired,
|
||||||
|
path: ["destination"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.destinationPort == null ||
|
||||||
|
!Number.isFinite(data.destinationPort) ||
|
||||||
|
data.destinationPort < 1
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceHttpPortRequired"),
|
||||||
|
path: ["destinationPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.mode === "http") {
|
||||||
|
if (!data.scheme) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceDownstreamSchemeRequired"),
|
||||||
|
path: ["scheme"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.destinationPort == null ||
|
||||||
|
!Number.isFinite(data.destinationPort) ||
|
||||||
|
data.destinationPort < 1
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceHttpPortRequired"),
|
||||||
|
path: ["destinationPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.httpConfigDomainId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("domainRequired"),
|
||||||
|
path: ["httpConfigDomainId"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function destinationRefine(
|
||||||
|
data: {
|
||||||
|
mode: PrivateResourceMode;
|
||||||
|
destination?: string | null;
|
||||||
|
authDaemonMode?: string | null;
|
||||||
|
destinationPort?: number | null;
|
||||||
|
scheme?: string;
|
||||||
|
httpConfigDomainId?: string | null;
|
||||||
|
},
|
||||||
|
ctx: z.RefinementCtx,
|
||||||
|
t: TranslateFn,
|
||||||
|
destinationRequired?: string
|
||||||
|
) {
|
||||||
|
const isNativeSsh = data.mode === "ssh" && data.authDaemonMode === "native";
|
||||||
|
const trimmedDestination = data.destination?.trim();
|
||||||
|
if (
|
||||||
|
!isNativeSsh &&
|
||||||
|
(!trimmedDestination || trimmedDestination.length < 1)
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: destinationRequired ?? "Destination is required",
|
||||||
|
path: ["destination"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.mode === "ssh" && !isNativeSsh) {
|
||||||
|
if (
|
||||||
|
data.destinationPort == null ||
|
||||||
|
!Number.isFinite(data.destinationPort) ||
|
||||||
|
data.destinationPort < 1
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceHttpPortRequired"),
|
||||||
|
path: ["destinationPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.mode === "http") {
|
||||||
|
if (!data.scheme) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceDownstreamSchemeRequired"),
|
||||||
|
path: ["scheme"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.destinationPort == null ||
|
||||||
|
!Number.isFinite(data.destinationPort) ||
|
||||||
|
data.destinationPort < 1
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("internalResourceHttpPortRequired"),
|
||||||
|
path: ["destinationPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.httpConfigDomainId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("domainRequired"),
|
||||||
|
path: ["httpConfigDomainId"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHostFormSchema(t: TranslateFn) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
siteIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
mode: z.literal("host"),
|
||||||
|
destination: z.string().nullish(),
|
||||||
|
alias: z.string().nullish(),
|
||||||
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
udpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
disableIcmp: z.boolean().optional(),
|
||||||
|
authDaemonMode: z
|
||||||
|
.enum(["site", "remote", "native"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
authDaemonPort: z.number().int().positive().optional().nullable()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCidrFormSchema(t: TranslateFn) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
siteIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
mode: z.literal("cidr"),
|
||||||
|
destination: z.string().nullish(),
|
||||||
|
tcpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
udpPortRangeString: createPortRangeStringSchema(t),
|
||||||
|
disableIcmp: z.boolean().optional()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpFormSchema(t: TranslateFn) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
siteIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
mode: z.literal("http"),
|
||||||
|
destination: z.string().nullish(),
|
||||||
|
destinationPort: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
|
ssl: z.boolean().optional(),
|
||||||
|
httpConfigSubdomain: z.string().nullish(),
|
||||||
|
httpConfigDomainId: z.string().nullish(),
|
||||||
|
httpConfigFullDomain: z.string().nullish()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => destinationRefine(data, ctx, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSshFormSchema(
|
||||||
|
t: TranslateFn,
|
||||||
|
options?: { isNative?: boolean }
|
||||||
|
) {
|
||||||
|
const isNative = options?.isNative ?? false;
|
||||||
|
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
siteIds: z.array(z.number().int().positive()).min(1),
|
||||||
|
mode: z.literal("ssh"),
|
||||||
|
destination: z.string().nullish(),
|
||||||
|
alias: z.string().nullish(),
|
||||||
|
destinationPort: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(65535)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
pamMode: z.enum(["passthrough", "push"]),
|
||||||
|
standardDaemonLocation: z.enum(["site", "remote"]),
|
||||||
|
authDaemonPort: z.string()
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
destinationRefine(
|
||||||
|
{
|
||||||
|
...data,
|
||||||
|
authDaemonMode: isNative
|
||||||
|
? "native"
|
||||||
|
: data.standardDaemonLocation
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
t
|
||||||
|
);
|
||||||
|
|
||||||
|
const showDaemonPort =
|
||||||
|
!isNative &&
|
||||||
|
data.pamMode === "push" &&
|
||||||
|
data.standardDaemonLocation === "remote";
|
||||||
|
|
||||||
|
if (showDaemonPort) {
|
||||||
|
const port = Number(data.authDaemonPort);
|
||||||
|
if (
|
||||||
|
!data.authDaemonPort.trim() ||
|
||||||
|
!Number.isInteger(port) ||
|
||||||
|
port < 1 ||
|
||||||
|
port > 65535
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: t("healthCheckPortInvalid"),
|
||||||
|
path: ["authDaemonPort"]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeFormValuesWithResource(
|
||||||
|
resource: SiteResourceData,
|
||||||
|
partial: Partial<PrivateResourceFormValues>
|
||||||
|
): PrivateResourceFormValues {
|
||||||
|
return {
|
||||||
|
...siteResourceToFormValues(resource),
|
||||||
|
...partial
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import SiteResourceContext, {
|
||||||
|
type SiteResourceAccessState
|
||||||
|
} from "@app/contexts/siteResourceContext";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
import {
|
||||||
|
accessTagsToIds,
|
||||||
|
type PrivateResourceAccessTag,
|
||||||
|
type PrivateResourceClient,
|
||||||
|
type SiteResourceData
|
||||||
|
} from "@app/lib/privateResourceForm";
|
||||||
|
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type SiteResourceProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
siteResource: SiteResourceData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteResourceProvider({
|
||||||
|
children,
|
||||||
|
siteResource: serverSiteResource
|
||||||
|
}: SiteResourceProviderProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const [siteResource, setSiteResource] =
|
||||||
|
useState<SiteResourceData>(serverSiteResource);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSiteResource(serverSiteResource);
|
||||||
|
}, [serverSiteResource]);
|
||||||
|
|
||||||
|
const resourceRolesQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceRoles({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const resourceUsersQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceUsers({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const resourceClientsQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceClients({
|
||||||
|
siteResourceId: siteResource.id
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const access = useMemo<SiteResourceAccessState>(() => {
|
||||||
|
const roles = (resourceRolesQuery.data ?? []) as {
|
||||||
|
roleId: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
const users = (resourceUsersQuery.data ?? []) as {
|
||||||
|
userId: string;
|
||||||
|
}[];
|
||||||
|
const clients = (resourceClientsQuery.data ?? []) as {
|
||||||
|
clientId: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleIds: roles
|
||||||
|
.filter((r) => r.name !== "Admin")
|
||||||
|
.map((r) => r.roleId),
|
||||||
|
userIds: users.map((u) => u.userId),
|
||||||
|
clientIds: clients.map((c) => c.clientId)
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
resourceRolesQuery.data,
|
||||||
|
resourceUsersQuery.data,
|
||||||
|
resourceClientsQuery.data
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [accessOverride, setAccessOverride] =
|
||||||
|
useState<SiteResourceAccessState | null>(null);
|
||||||
|
|
||||||
|
const resolvedAccess = accessOverride ?? access;
|
||||||
|
|
||||||
|
const updateSiteResource = (updated: Partial<SiteResourceData>) => {
|
||||||
|
if (!siteResource) {
|
||||||
|
throw new Error(t("resourceErrorNoUpdate"));
|
||||||
|
}
|
||||||
|
setSiteResource((prev) => ({ ...prev, ...updated }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteResourceContext.Provider
|
||||||
|
value={{
|
||||||
|
siteResource,
|
||||||
|
updateSiteResource,
|
||||||
|
access: resolvedAccess,
|
||||||
|
setAccess: setAccessOverride
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SiteResourceContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccessFormDefaults(orgId: string, siteResourceId: number) {
|
||||||
|
const resourceRolesQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceRoles({ siteResourceId })
|
||||||
|
);
|
||||||
|
const resourceUsersQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceUsers({ siteResourceId })
|
||||||
|
);
|
||||||
|
const resourceClientsQuery = useQuery(
|
||||||
|
resourceQueries.siteResourceClients({ siteResourceId })
|
||||||
|
);
|
||||||
|
const clientsQuery = useQuery(
|
||||||
|
orgQueries.machineClients({
|
||||||
|
orgId,
|
||||||
|
perPage: 1
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
resourceRolesQuery.isLoading ||
|
||||||
|
resourceUsersQuery.isLoading ||
|
||||||
|
resourceClientsQuery.isLoading;
|
||||||
|
|
||||||
|
const roles: PrivateResourceAccessTag[] = (
|
||||||
|
(resourceRolesQuery.data ?? []) as { roleId: number; name: string }[]
|
||||||
|
)
|
||||||
|
.map((i) => ({ id: i.roleId.toString(), text: i.name }))
|
||||||
|
.filter((r) => r.text !== "Admin");
|
||||||
|
|
||||||
|
const users: PrivateResourceAccessTag[] = (
|
||||||
|
(resourceUsersQuery.data ?? []) as {
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
type?: string;
|
||||||
|
idpName?: string;
|
||||||
|
}[]
|
||||||
|
).map((i) => ({
|
||||||
|
id: i.userId.toString(),
|
||||||
|
text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
const clients: PrivateResourceClient[] = [
|
||||||
|
...((resourceClientsQuery.data ?? []) as {
|
||||||
|
clientId: number;
|
||||||
|
name: string;
|
||||||
|
}[])
|
||||||
|
];
|
||||||
|
|
||||||
|
const allClients = (clientsQuery.data ?? []).filter((c) => !c.userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
roles,
|
||||||
|
users,
|
||||||
|
clients,
|
||||||
|
hasMachineClients: allClients.length > 0,
|
||||||
|
accessIds: accessTagsToIds({ roles, users, clients })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SiteResourceProvider;
|
||||||
Reference in New Issue
Block a user