diff --git a/README.md b/README.md index a842ed3b..27105c70 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ [![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack) [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) -[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) +[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@pangolin-net) diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml index 17289ef2..0fb95109 100644 --- a/install/config/crowdsec/docker-compose.yml +++ b/install/config/crowdsec/docker-compose.yml @@ -9,10 +9,15 @@ services: PARSERS: crowdsecurity/whitelists ENROLL_TAGS: docker healthcheck: - interval: 10s - retries: 15 - timeout: 10s - test: ["CMD", "cscli", "capi", "status"] + test: + - CMD + - cscli + - lapi + - status + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s labels: - "traefik.enable=false" # Disable traefik for crowdsec volumes: diff --git a/install/config/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml index cac5fa6e..c58d5670 100644 --- a/install/config/crowdsec/dynamic_config.yml +++ b/install/config/crowdsec/dynamic_config.yml @@ -44,7 +44,7 @@ http: crowdsecAppsecUnreachableBlock: true # Block on unreachable crowdsecAppsecBodyLimit: 10485760 crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later - crowdsecLapiHost: crowdsec:8080 # CrowdSec + crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiScheme: http # CrowdSec API scheme forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) @@ -106,4 +106,13 @@ http: api-service: loadBalancer: servers: - - url: "http://pangolin:3000" # API/WebSocket server \ No newline at end of file + - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 diff --git a/messages/en-US.json b/messages/en-US.json index 0cfd8f6f..e0728c94 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -51,6 +51,9 @@ "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", + "sitesBannerTitle": "Connect Any Network", + "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", + "sitesBannerButtonText": "Install Site", "siteCreate": "Create Site", "siteCreateDescription2": "Follow the steps below to create and connect a new site", "siteCreateDescription": "Create a new site to start connecting resources", @@ -147,8 +150,12 @@ "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", + "proxyResourcesBannerTitle": "Web-based Public Access", + "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", + "privateResourcesBannerTitle": "Zero-Trust Private Access", + "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -158,9 +165,9 @@ "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceHTTP": "HTTPS Resource", - "resourceHTTPDescription": "Proxy requests to the app over HTTPS using a subdomain or base domain.", + "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceRaw": "Raw TCP/UDP Resource", - "resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.", + "resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", "resourceSeeAll": "See All Resources", @@ -946,7 +953,7 @@ "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit Code", "passwordResetSubmit": "Request Reset", - "passwordResetAlreadyHaveCode": "Enter Password Reset Code", + "passwordResetAlreadyHaveCode": "Enter Code", "passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", @@ -1944,8 +1951,15 @@ "beta": "Beta", "manageUserDevices": "User Devices", "manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", + "downloadClientBannerTitle": "Download Pangolin Client", + "downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately", "manageMachineClients": "Manage Machine Clients", "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", + "machineClientsBannerTitle": "Servers & Automated Systems", + "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", @@ -2318,5 +2332,19 @@ "resourceLoginPageDescription": "Customize the login page for individual resources", "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", - "defaultIdentityProvider": "Default Identity Provider" + "defaultIdentityProvider": "Default Identity Provider", + "editInternalResourceDialogNetworkSettings": "Network Settings", + "editInternalResourceDialogAccessPolicy": "Access Policy", + "editInternalResourceDialogAddRoles": "Add Roles", + "editInternalResourceDialogAddUsers": "Add Users", + "editInternalResourceDialogAddClients": "Add Clients", + "editInternalResourceDialogDestinationLabel": "Destination", + "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Access Control", + "editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.", + "editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535." } diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 2ee34da6..5b357d06 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -81,6 +81,7 @@ function createDb() { export const db = createDb(); export default db; +export const primaryDb = db.$primary; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0f696df6..9cbc8d7b 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -20,6 +20,7 @@ function createDb() { export const db = createDb(); export default db; +export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index d8ecd456..cd1218ce 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, driver } from "@server/db"; +import { db, requestAuditLog, driver, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -74,12 +74,12 @@ async function query(query: Q) { ); } - const [all] = await db + const [all] = await primaryDb .select({ total: count() }) .from(requestAuditLog) .where(baseConditions); - const [blocked] = await db + const [blocked] = await primaryDb .select({ total: count() }) .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); @@ -88,7 +88,9 @@ async function query(query: Q) { .mapWith(Number) .as("total"); - const requestsPerCountry = await db + const DISTINCT_LIMIT = 500; + + const requestsPerCountry = await primaryDb .selectDistinct({ code: requestAuditLog.location, count: totalQ @@ -96,7 +98,16 @@ async function query(query: Q) { .from(requestAuditLog) .where(and(baseConditions, not(isNull(requestAuditLog.location)))) .groupBy(requestAuditLog.location) - .orderBy(desc(totalQ)); + .orderBy(desc(totalQ)) + .limit(DISTINCT_LIMIT+1); + + if (requestsPerCountry.length > DISTINCT_LIMIT) { + // throw an error + throw createHttpError( + HttpCode.BAD_REQUEST, + `Too many distinct countries. Please narrow your query.` + ); + } const groupByDayFunction = driver === "pg" @@ -106,7 +117,7 @@ async function query(query: Q) { const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`; - const requestsPerDay = await db + const requestsPerDay = await primaryDb .select({ day: groupByDayFunction.as("day"), allowedCount: diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index b658dbb5..602b4475 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, resources } from "@server/db"; +import { db, primaryDb, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -107,7 +107,7 @@ function getWhere(data: Q) { } export function queryRequest(data: Q) { - return db + return primaryDb .select({ id: requestAuditLog.id, timestamp: requestAuditLog.timestamp, @@ -143,7 +143,7 @@ export function queryRequest(data: Q) { } export function countRequestQuery(data: Q) { - const countQuery = db + const countQuery = primaryDb .select({ count: count() }) .from(requestAuditLog) .where(getWhere(data)); @@ -173,50 +173,61 @@ async function queryUniqueFilterAttributes( eq(requestAuditLog.orgId, orgId) ); - // Get unique actors - const uniqueActors = await db - .selectDistinct({ - actor: requestAuditLog.actor - }) - .from(requestAuditLog) - .where(baseConditions); + const DISTINCT_LIMIT = 500; - // Get unique locations - const uniqueLocations = await db - .selectDistinct({ - locations: requestAuditLog.location - }) - .from(requestAuditLog) - .where(baseConditions); + // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!! - // Get unique actors - const uniqueHosts = await db - .selectDistinct({ - hosts: requestAuditLog.host - }) - .from(requestAuditLog) - .where(baseConditions); + // Run all queries in parallel + const [ + uniqueActors, + uniqueLocations, + uniqueHosts, + uniquePaths, + uniqueResources + ] = await Promise.all([ + primaryDb + .selectDistinct({ actor: requestAuditLog.actor }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ locations: requestAuditLog.location }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ hosts: requestAuditLog.host }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ paths: requestAuditLog.path }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1) + ]); - // Get unique actors - const uniquePaths = await db - .selectDistinct({ - paths: requestAuditLog.path - }) - .from(requestAuditLog) - .where(baseConditions); - - // Get unique resources with names - const uniqueResources = await db - .selectDistinct({ - id: requestAuditLog.resourceId, - name: resources.name - }) - .from(requestAuditLog) - .leftJoin( - resources, - eq(requestAuditLog.resourceId, resources.resourceId) - ) - .where(baseConditions); + if ( + uniqueActors.length > DISTINCT_LIMIT || + uniqueLocations.length > DISTINCT_LIMIT || + uniqueHosts.length > DISTINCT_LIMIT || + uniquePaths.length > DISTINCT_LIMIT || + uniqueResources.length > DISTINCT_LIMIT + ) { + throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); + } return { actors: uniqueActors @@ -295,6 +306,12 @@ export async function queryRequestAuditLogs( }); } catch (error) { logger.error(error); + // if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message + if (error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range.") { + return next( + createHttpError(HttpCode.BAD_REQUEST, error.message) + ); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index 3ec90857..bca9412c 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -6,8 +6,8 @@ export type GetCertificateResponse = { status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; - createdAt: string; - updatedAt: string; + createdAt: number; + updatedAt: number; errorMessage?: string | null; renewalCount: number; }; diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index b6dc8148..c8ede518 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -194,11 +194,23 @@ export async function getOlmToken( .where(inArray(exitNodes.exitNodeId, exitNodeIds)); } + // Map exitNodeId to siteIds + const exitNodeIdToSiteIds: Record = {}; + for (const { sites: site } of clientSites) { + if (site.exitNodeId !== null) { + if (!exitNodeIdToSiteIds[site.exitNodeId]) { + exitNodeIdToSiteIds[site.exitNodeId] = []; + } + exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); + } + } + const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { publicKey: exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, - endpoint: exitNode.endpoint + endpoint: exitNode.endpoint, + siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index c50a800b..d2196e87 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -2,6 +2,7 @@ import { clientSiteResources, db, newts, + orgs, roles, roleSiteResources, SiteResource, @@ -10,7 +11,7 @@ import { userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; -import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip"; +import { getNextAvailableAliasAddress, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -84,8 +85,7 @@ const createSiteResourceSchema = z if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z - // .union([z.cidrv4(), z.cidrv6()]) - .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -175,6 +175,39 @@ export async function createSiteResource( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found")); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + // // check if resource with same protocol and proxy port already exists (only for port mode) // if (mode === "port" && protocol && proxyPort) { // const [existingResource] = await db diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 10708443..c0383616 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -5,6 +5,7 @@ import { clientSiteResourcesAssociationsCache, db, newts, + orgs, roles, roleSiteResources, sites, @@ -24,6 +25,7 @@ import { generateAliasConfig, generateRemoteSubnets, generateSubnetProxyTargets, + isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { @@ -96,8 +98,7 @@ const updateSiteResourceSchema = z if (data.mode === "cidr" && data.destination) { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z - // .union([z.cidrv4(), z.cidrv6()]) - .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -196,6 +197,39 @@ export async function updateSiteResource( ); } + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existingSiteResource.orgId)) + .limit(1); + + if (!org) { + return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found")); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination!, org.subnet) || isIpInCidr(destination!, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + let existingSite = site; let siteChanged = false; if (existingSiteResource.siteId !== siteId) { diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index c8dba38c..786c8635 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -303,6 +303,24 @@ export default function Page() { +
+
+ + {t("idpType")} + +
+ { + handleProviderChange( + value as "oidc" | "google" | "azure" + ); + }} + cols={3} + /> +
+
- -
-
- - {t("idpType")} - -
- { - handleProviderChange( - value as "oidc" | "google" | "azure" - ); - }} - cols={3} - /> -
diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index e1a904ad..f2618bc2 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -1,6 +1,7 @@ import type { ClientRow } from "@app/components/MachineClientsTable"; import MachineClientsTable from "@app/components/MachineClientsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; @@ -71,6 +72,8 @@ export default async function ClientsPage(props: ClientsPageProps) { description={t("manageMachineClientsDescription")} /> + + + + + {resourceTypes.length > 1 && ( + <> +
+ + {t("type")} + +
+ + { + baseForm.setValue( + "http", + value === "http" + ); + // Update method default when switching resource type + addTargetForm.setValue( + "method", + value === "http" + ? "http" + : null + ); + }} + cols={2} + /> + + )} +
- - {resourceTypes.length > 1 && ( - <> -
- - {t("type")} - -
- - { - baseForm.setValue( - "http", - value === "http" - ); - // Update method default when switching resource type - addTargetForm.setValue( - "method", - value === "http" - ? "http" - : null - ); - }} - cols={2} - /> - - )}
@@ -1684,7 +1684,7 @@ export default function Page() { ) : ( -
+

{t("targetNoOne")}

diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index a59f790f..408a9352 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -1,6 +1,7 @@ import type { ResourceRow } from "@app/components/ProxyResourcesTable"; import ProxyResourcesTable from "@app/components/ProxyResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; @@ -97,6 +98,8 @@ export default async function ProxyResourcesPage( description={t("proxyResourceDescription")} /> + + + {tunnelTypes.length > 1 && ( + <> +
+ + {t("type")} + +
+ { + form.setValue("method", value); + }} + cols={3} + /> + + )} +
{ @@ -748,26 +768,6 @@ WantedBy=default.target` )}
- - {tunnelTypes.length > 1 && ( - <> -
- - {t("type")} - -
- { - form.setValue("method", value); - }} - cols={3} - /> - - )}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 71ba9dc3..132f0c05 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import SitesTable, { SiteRow } from "../../../../components/SitesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import SitesBanner from "@app/components/SitesBanner"; import SitesSplashCard from "../../../../components/SitesSplashCard"; import { getTranslations } from "next-intl/server"; @@ -67,6 +68,8 @@ export default async function SitesPage(props: SitesPageProps) { description={t("siteDescription")} /> + + ); diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index fe869935..acb2f358 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -209,22 +209,22 @@ export default function Page() { -
-
- - {t("idpType")} - -
- - { - form.setValue("type", value as "oidc"); - }} - cols={3} - /> -
+ {/*
*/} + {/*
*/} + {/* */} + {/* {t("idpType")} */} + {/* */} + {/*
*/} + {/* */} + {/* { */} + {/* form.setValue("type", value as "oidc"); */} + {/* }} */} + {/* cols={3} */} + {/* /> */} + {/*
*/} diff --git a/src/components/ClientDownloadBanner.tsx b/src/components/ClientDownloadBanner.tsx new file mode 100644 index 00000000..dcd572fd --- /dev/null +++ b/src/components/ClientDownloadBanner.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Download } from "lucide-react"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +export const ClientDownloadBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("downloadClientBannerDescription")} + > + + + + + + + + + + + ); +}; + +export default ClientDownloadBanner; + diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 023ef00c..4cd02743 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -317,6 +317,9 @@ export default function ClientResourcesTable({ defaultSort={defaultSort} enableColumnVisibility={true} persistColumnVisibility="internal-resources" + columnVisibility={{ + niceId: false + }} stickyLeftColumn="name" stickyRightColumn="actions" /> @@ -329,8 +332,11 @@ export default function ClientResourcesTable({ orgId={orgId} sites={sites} onSuccess={() => { - router.refresh(); - setEditingResource(null); + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + setEditingResource(null); + }, 150); }} /> )} @@ -341,7 +347,10 @@ export default function ClientResourcesTable({ orgId={orgId} sites={sites} onSuccess={() => { - router.refresh(); + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + }, 150); }} /> diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 00e8ce96..afdaa77e 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -58,6 +58,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; // import { InfoPopup } from "@app/components/ui/info-popup"; // Helper to validate port range string format @@ -108,17 +109,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { }; // Port range string schema for client-side validation -const portRangeStringSchema = z - .string() - .optional() - .nullable() - .refine( - (val) => isValidPortRangeString(val), - { - message: - 'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.' - } - ); +// Note: This schema is defined outside the component, so we'll use a function to get the message +const getPortRangeValidationMessage = (t: (key: string) => string) => + t("editInternalResourceDialogPortRangeValidationError"); + +const createPortRangeStringSchema = (t: (key: string) => string) => + z + .string() + .optional() + .nullable() + .refine((val) => isValidPortRangeString(val), { + message: getPortRangeValidationMessage(t) + }); // Helper to determine the port mode from a port range string type PortMode = "all" | "blocked" | "custom"; @@ -161,25 +163,18 @@ export default function CreateInternalResourceDialog({ .string() .min(1, t("createInternalResourceDialogNameRequired")) .max(255, t("createInternalResourceDialogNameMaxLength")), - // mode: z.enum(["host", "cidr", "port"]), - mode: z.enum(["host", "cidr"]), - destination: z.string().min(1), siteId: z .int() .positive(t("createInternalResourceDialogPleaseSelectSite")), - // protocol: z.enum(["tcp", "udp"]), - // proxyPort: z.int() - // .positive() - // .min(1, t("createInternalResourceDialogProxyPortMin")) - // .max(65535, t("createInternalResourceDialogProxyPortMax")), - // destinationPort: z.int() - // .positive() - // .min(1, t("createInternalResourceDialogDestinationPortMin")) - // .max(65535, t("createInternalResourceDialogDestinationPortMax")) - // .nullish(), + // mode: z.enum(["host", "cidr", "port"]), + mode: z.enum(["host", "cidr"]), + // protocol: z.enum(["tcp", "udp"]).nullish(), + // proxyPort: z.int().positive().min(1, t("createInternalResourceDialogProxyPortMin")).max(65535, t("createInternalResourceDialogProxyPortMax")).nullish(), + destination: z.string().min(1), + // destinationPort: z.int().positive().min(1, t("createInternalResourceDialogDestinationPortMin")).max(65535, t("createInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), - tcpPortRangeString: portRangeStringSchema, - udpPortRangeString: portRangeStringSchema, + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), roles: z .array( @@ -453,8 +448,8 @@ export default function CreateInternalResourceDialog({ variant: "default" }); - onSuccess?.(); setOpen(false); + onSuccess?.(); } catch (error) { console.error("Error creating internal resource:", error); toast({ @@ -498,7 +493,7 @@ export default function CreateInternalResourceDialog({ return ( - + {t("createInternalResourceDialogCreateClientResource")} @@ -516,179 +511,180 @@ export default function CreateInternalResourceDialog({ className="space-y-6" id="create-internal-resource-form" > - {/* Resource Properties Form */} -
-

- {t( - "createInternalResourceDialogResourceProperties" + {/* Name and Site - Side by Side */} +
+ ( + + + {t( + "createInternalResourceDialogName" + )} + + + + + + )} -

-
- ( - - - {t( - "createInternalResourceDialogName" - )} - - - - - - - )} - /> + /> - ( - - - {t( - "site" - )} - - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - ( - site - ) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> - - ( - - - {t( - "createInternalResourceDialogMode" - )} - - - - - )} - /> - {/* - {mode === "port" && ( - <> -
+ /> + + + {t( + "noSitesFound" + )} + + + {availableSites.map( + (site) => ( + { + field.onChange( + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + + )} + /> +
+ + {/* Tabs for Network Settings and Access Control */} + + {/* Network Settings Tab */} +
+
+
+ +
+ {t( + "editInternalResourceDialogDestinationDescription" + )} +
+
+ +
+ {/* Mode - Smaller select */} +
( - {t("createInternalResourceDialogProtocol")} + {t( + "createInternalResourceDialogMode" + )} @@ -708,22 +708,29 @@ export default function CreateInternalResourceDialog({ )} /> +
+ {/* Destination - Larger input */} +
( - {t("createInternalResourceDialogSitePort")} + + {t( + "createInternalResourceDialogDestination" + )} + - field.onChange( - e.target.value === "" ? undefined : parseInt(e.target.value) - ) - } + {...field} /> @@ -731,418 +738,396 @@ export default function CreateInternalResourceDialog({ )} />
- - )} */} -
-
- {/* Target Configuration Form */} -
-

- {t( - "createInternalResourceDialogTargetConfiguration" - )} -

-
- ( - - - {t( - "createInternalResourceDialogDestination" - )} - - - - - - {mode === "host" && - t( - "createInternalResourceDialogDestinationHostDescription" + {/* Alias - Equally sized input (if allowed) */} + {mode !== "cidr" && ( +
+ ( + + + {t( + "createInternalResourceDialogAlias" + )} + + + + + + )} - {mode === "cidr" && - t( - "createInternalResourceDialogDestinationCidrDescription" - )} - {/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */} - - - - )} - /> - - {/* {mode === "port" && ( - ( - - - {t("targetPort")} - - - - field.onChange( - e.target.value === "" ? undefined : parseInt(e.target.value) - ) - } - /> - - - {t("createInternalResourceDialogDestinationPortDescription")} - - - + /> +
)} - /> - )} */} -
-
+
+
- {/* Alias */} - {mode !== "cidr" && ( -
- ( - - + {/* Ports and Restrictions */} +
+ {/* TCP Ports */} +
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
+
+ {t( - "createInternalResourceDialogAlias" + "editInternalResourceDialogTcp" )} - - - - +
+
+ ( + +
+ {/**/} + + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* UDP Ports */} +
+
+ {t( - "createInternalResourceDialogAliasDescription" + "editInternalResourceDialogUdp" )} - - - - )} - /> -
- )} - - {/* Port Restrictions Section */} -
-

- {t("portRestrictions")} -

-
- {/* TCP Ports */} - ( - -
- - TCP - - {/**/} - - {tcpPortMode === "custom" ? ( - - - setTcpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* UDP Ports */} - ( - -
- - UDP - - {/**/} - - {udpPortMode === "custom" ? ( - - - setUdpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* ICMP Toggle */} - ( - -
- - ICMP - - - field.onChange(!checked)} - /> - - - {field.value ? t("blocked") : t("allowed")} - -
- -
- )} - /> -
-
- - {/* Access Control Section */} -
-

- {t("resourceUsersRoles")} -

-
- ( - - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - +
+
+ ( + +
+ {/**/} + + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* ICMP Toggle */} +
+
+ {t( - "resourceRoleDescription" + "editInternalResourceDialogIcmp" )} - - - )} - /> - ( - - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - {hasMachineClients && ( +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t( + "blocked" + ) + : t( + "allowed" + )} + +
+ +
+ )} + /> +
+
+
+
+ + {/* Access Control Tab */} +
+
+ +
+ {t( + "editInternalResourceDialogAccessControlDescription" + )} +
+
+
+ {/* Roles */} ( - {t("machineClients")} + {t("roles")} { form.setValue( - "clients", - newClients as [ + "roles", + newRoles as [ Tag, ...Tag[] ] @@ -1152,7 +1137,70 @@ export default function CreateInternalResourceDialog({ true } autocompleteOptions={ - allClients + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + + {/* Users */} + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers } allowDuplicates={ false @@ -1167,9 +1215,76 @@ export default function CreateInternalResourceDialog({ )} /> - )} + + {/* Clients (Machines) */} + {hasMachineClients && ( + ( + + + {t( + "machineClients" + )} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={ + true + } + /> + + + + )} + /> + )} +
-
+ diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index ccb5c497..e64a4c70 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -17,6 +17,7 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import BrandingLogo from "@app/components/BrandingLogo"; import { useTranslations } from "next-intl"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; type DashboardLoginFormProps = { redirect?: string; @@ -48,9 +49,14 @@ export default function DashboardLoginForm({ ? env.branding.logo?.authPage?.height || 58 : 58; + const gradientClasses = + build === "saas" + ? "border-b border-primary/30 bg-gradient-to-br dark:from-primary/20 from-primary/20 via-background to-background overflow-hidden rounded-t-lg" + : "border-b"; + return ( - +
diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx new file mode 100644 index 00000000..6f49e036 --- /dev/null +++ b/src/components/DismissableBanner.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useState, useEffect, type ReactNode } from "react"; +import { Card, CardContent } from "@app/components/ui/card"; +import { X } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type DismissableBannerProps = { + storageKey: string; + version: number; + title: string; + titleIcon: ReactNode; + description: string; + children?: ReactNode; +}; + +export const DismissableBanner = ({ + storageKey, + version, + title, + titleIcon, + description, + children +}: DismissableBannerProps) => { + const [isDismissed, setIsDismissed] = useState(true); + const t = useTranslations(); + + useEffect(() => { + const dismissedData = localStorage.getItem(storageKey); + if (dismissedData) { + try { + const parsed = JSON.parse(dismissedData); + // If version matches, use the dismissed state + if (parsed.version === version) { + setIsDismissed(parsed.dismissed); + } else { + // Version changed, show the banner again + setIsDismissed(false); + } + } catch { + // If parsing fails, check for old format (just "true" string) + if (dismissedData === "true") { + // Old format, show banner again for new version + setIsDismissed(false); + } else { + setIsDismissed(true); + } + } + } else { + setIsDismissed(false); + } + }, [storageKey, version]); + + const handleDismiss = () => { + setIsDismissed(true); + localStorage.setItem( + storageKey, + JSON.stringify({ dismissed: true, version }) + ); + }; + + if (isDismissed) { + return null; + } + + return ( + + + +
+
+

+ {titleIcon} + {title} +

+

+ {description} +

+
+ {children && ( +
+ {children} +
+ )} +
+
+
+ ); +}; + +export default DismissableBanner; + diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 5d5745c7..88d98aa5 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -56,7 +56,14 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { ListSitesResponse } from "@server/routers/site"; -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, ChevronDown } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; +import { Separator } from "@app/components/ui/separator"; // import { InfoPopup } from "@app/components/ui/info-popup"; // Helper to validate port range string format @@ -85,7 +92,12 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { return false; } - if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) { + if ( + startPort < 1 || + startPort > 65535 || + endPort < 1 || + endPort > 65535 + ) { return false; } @@ -107,17 +119,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { }; // Port range string schema for client-side validation -const portRangeStringSchema = z - .string() - .optional() - .nullable() - .refine( - (val) => isValidPortRangeString(val), - { - message: - 'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.' - } - ); +// Note: This schema is defined outside the component, so we'll use a function to get the message +const getPortRangeValidationMessage = (t: (key: string) => string) => + t("editInternalResourceDialogPortRangeValidationError"); + +const createPortRangeStringSchema = (t: (key: string) => string) => + z + .string() + .optional() + .nullable() + .refine((val) => isValidPortRangeString(val), { + message: getPortRangeValidationMessage(t) + }); // Helper to determine the port mode from a port range string type PortMode = "all" | "blocked" | "custom"; @@ -128,7 +141,10 @@ const getPortModeFromString = (val: string | undefined | null): PortMode => { }; // Helper to get the port string for API from mode and custom value -const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => { +const getPortStringFromMode = ( + mode: PortMode, + customValue: string +): string | undefined => { if (mode === "all") return "*"; if (mode === "blocked") return ""; return customValue; @@ -188,8 +204,8 @@ export default function EditInternalResourceDialog({ destination: z.string().min(1), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), - tcpPortRangeString: portRangeStringSchema, - udpPortRangeString: portRangeStringSchema, + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), roles: z .array( @@ -352,6 +368,9 @@ export default function EditInternalResourceDialog({ number | null >(null); + // Collapsible state for ports and restrictions + const [isPortsExpanded, setIsPortsExpanded] = useState(false); + // Port restriction UI state const [tcpPortMode, setTcpPortMode] = useState( getPortModeFromString(resource.tcpPortRangeString) @@ -446,30 +465,27 @@ export default function EditInternalResourceDialog({ } // Update the site resource - await api.post( - `/site-resource/${resource.id}`, - { - name: data.name, - siteId: data.siteId, - mode: data.mode, - // protocol: data.mode === "port" ? data.protocol : null, - // proxyPort: data.mode === "port" ? data.proxyPort : null, - // destinationPort: data.mode === "port" ? data.destinationPort : null, - destination: data.destination, - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - 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 api.post(`/site-resource/${resource.id}`, { + name: data.name, + siteId: data.siteId, + mode: data.mode, + // protocol: data.mode === "port" ? data.protocol : null, + // proxyPort: data.mode === "port" ? data.proxyPort : null, + // destinationPort: data.mode === "port" ? data.destinationPort : null, + destination: data.destination, + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + 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)) + }); // Update roles, users, and clients // await Promise.all([ @@ -502,8 +518,8 @@ export default function EditInternalResourceDialog({ variant: "default" }); - onSuccess?.(); setOpen(false); + onSuccess?.(); } catch (error) { console.error("Error updating internal resource:", error); toast({ @@ -543,18 +559,26 @@ export default function EditInternalResourceDialog({ clients: [] }); // Reset port mode state - setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); - setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); + setTcpPortMode( + getPortModeFromString(resource.tcpPortRangeString) + ); + setUdpPortMode( + getPortModeFromString(resource.udpPortRangeString) + ); setTcpCustomPorts( - resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( - resource.udpPortRangeString && resource.udpPortRangeString !== "*" + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); + // Reset visibility states + setIsPortsExpanded(false); previousResourceId.current = resource.id; } @@ -602,25 +626,33 @@ export default function EditInternalResourceDialog({ clients: [] }); // Reset port mode state - setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); - setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); + setTcpPortMode( + getPortModeFromString(resource.tcpPortRangeString) + ); + setUdpPortMode( + getPortModeFromString(resource.udpPortRangeString) + ); setTcpCustomPorts( - resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( - resource.udpPortRangeString && resource.udpPortRangeString !== "*" + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); + // Reset visibility states + setIsPortsExpanded(false); // Reset previous resource ID to ensure clean state on next open previousResourceId.current = null; } setOpen(open); }} > - + {t("editInternalResourceDialogEditClientResource")} @@ -639,627 +671,628 @@ export default function EditInternalResourceDialog({ className="space-y-6" id="edit-internal-resource-form" > - {/* Resource Properties Form */} -
-

- {t( - "editInternalResourceDialogResourceProperties" + {/* Name and Site - Side by Side */} +
+ ( + + + {t( + "editInternalResourceDialogName" + )} + + + + + + )} -

-
- ( - - - {t( - "editInternalResourceDialogName" - )} - - - - - - - )} - /> + /> - ( - - - {t( - "site" - )} - - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - ( - site - ) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> - - ( - - - {t( - "editInternalResourceDialogMode" - )} - - - - - )} - /> - - {/* {mode === "port" && ( -
- ( - - {t("editInternalResourceDialogProtocol")} - - - - )} - /> - - ( - - {t("editInternalResourceDialogSitePort")} - - field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)} - /> - - - - )} - /> -
- )} */} -
-
- - {/* Target Configuration Form */} -
-

- {t( - "editInternalResourceDialogTargetConfiguration" + {field.value + ? availableSites.find( + (site) => + site.siteId === + field.value + )?.name + : t( + "selectSite" + )} + + + + + + + + + + {t( + "noSitesFound" + )} + + + {availableSites.map( + (site) => ( + { + field.onChange( + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + )} -

-
- ( - - - {t( - "editInternalResourceDialogDestination" - )} - - - - - - {mode === "host" && - t( - "editInternalResourceDialogDestinationHostDescription" - )} - {mode === "cidr" && - t( - "editInternalResourceDialogDestinationCidrDescription" - )} - {/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */} - - - - )} - /> - - {/* {mode === "port" && ( - ( - - {t("targetPort")} - - field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)} - /> - - - - )} - /> - )} */} -
+ />
- {/* Alias */} - {mode !== "cidr" && ( -
- ( - - - {t( - "editInternalResourceDialogAlias" + {/* Tabs for Network Settings and Access Control */} + + {/* Network Settings Tab */} +
+
+
+ +
+ {t( + "editInternalResourceDialogDestinationDescription" + )} +
+
+ +
+ {/* Mode - Smaller select */} +
+ ( + + + {t( + "editInternalResourceDialogMode" + )} + + + + )} - - - +
+ + {/* Destination - Larger input */} +
+ ( + + + {t( + "editInternalResourceDialogDestination" + )} + + + + + + + )} + /> +
+ + {/* Alias - Equally sized input (if allowed) */} + {mode !== "cidr" && ( +
+ ( + + + {t( + "editInternalResourceDialogAlias" + )} + + + + + + + )} /> - - - {t( - "editInternalResourceDialogAliasDescription" - )} - - - - )} - /> -
- )} - - {/* Port Restrictions Section */} -
-

- {t("portRestrictions")} -

-
- {/* TCP Ports */} - ( - -
- - TCP - - {/**/} - - {tcpPortMode === "custom" ? ( - - - setTcpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )}
- -
- )} - /> - - {/* UDP Ports */} - ( - -
- - UDP - - {/**/} - - {udpPortMode === "custom" ? ( - - - setUdpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* ICMP Toggle */} - ( - -
- - ICMP - - - field.onChange(!checked)} - /> - - - {field.value ? t("blocked") : t("allowed")} - -
- -
- )} - /> -
-
- - {/* Access Control Section */} -
-

- {t("resourceUsersRoles")} -

- {loadingRolesUsers ? ( -
- {t("loading")} + )} +
- ) : ( + + {/* Ports and Restrictions */}
- ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - + {/* TCP Ports */} +
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
- ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - + > +
+ + {t( + "editInternalResourceDialogTcp" + )} + +
+
+ ( + +
+ {/**/} + + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* UDP Ports */} +
- {hasMachineClients && ( + > +
+ + {t( + "editInternalResourceDialogUdp" + )} + +
+
+ ( + +
+ {/**/} + + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* ICMP Toggle */} +
+
+ + {t( + "editInternalResourceDialogIcmp" + )} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t( + "blocked" + ) + : t( + "allowed" + )} + +
+ +
+ )} + /> +
+
+
+
+ + {/* Access Control Tab */} +
+
+ +
+ {t( + "editInternalResourceDialogAccessControlDescription" + )} +
+
+ {loadingRolesUsers ? ( +
+ {t("loading")} +
+ ) : ( +
+ {/* Roles */} ( - {t( - "machineClients" - )} + {t("roles")} { form.setValue( - "clients", - newClients as [ + "roles", + newRoles as [ Tag, ...Tag[] ] @@ -1269,7 +1302,7 @@ export default function EditInternalResourceDialog({ true } autocompleteOptions={ - machineClients + allRoles } allowDuplicates={ false @@ -1284,10 +1317,135 @@ export default function EditInternalResourceDialog({ )} /> - )} -
- )} -
+ + {/* Users */} + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + {/* Clients (Machines) */} + {hasMachineClients && ( + ( + + + {t( + "machineClients" + )} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + machineClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={ + true + } + /> + + + + )} + /> + )} +
+ )} +
+
diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 72093e0d..717a3c12 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; @@ -20,17 +20,22 @@ interface HorizontalTabsProps { children: React.ReactNode; items: TabItem[]; disabled?: boolean; + clientSide?: boolean; + defaultTab?: number; } export function HorizontalTabs({ children, items, - disabled = false + disabled = false, + clientSide = false, + defaultTab = 0 }: HorizontalTabsProps) { const pathname = usePathname(); const params = useParams(); const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const t = useTranslations(); + const [activeClientTab, setActiveClientTab] = useState(defaultTab); function hydrateHref(href: string) { return href @@ -43,6 +48,73 @@ export function HorizontalTabs({ .replace("{remoteExitNodeId}", params.remoteExitNodeId as string); } + // Client-side mode: render tabs as buttons with state management + if (clientSide) { + const childrenArray = React.Children.toArray(children); + const activeChild = childrenArray[activeClientTab] || null; + + return ( +
+
+
+
+ {items.map((item, index) => { + const isActive = activeClientTab === index; + const isProfessional = + item.showProfessional && !isUnlocked(); + const isDisabled = + disabled || + (isProfessional && !isUnlocked()); + + return ( + + ); + })} +
+
+
+
{activeChild}
+
+ ); + } + + // Server-side mode: original behavior with routing return (
diff --git a/src/components/MachineClientsBanner.tsx b/src/components/MachineClientsBanner.tsx new file mode 100644 index 00000000..f69fa061 --- /dev/null +++ b/src/components/MachineClientsBanner.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Server, Terminal, Container } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +type MachineClientsBannerProps = { + orgId: string; +}; + +export const MachineClientsBanner = ({ + orgId +}: MachineClientsBannerProps) => { + const t = useTranslations(); + + return ( + } + description={t("machineClientsBannerDescription")} + > + + + + + + + + ); +}; + +export default MachineClientsBanner; + diff --git a/src/components/PrivateResourcesBanner.tsx b/src/components/PrivateResourcesBanner.tsx new file mode 100644 index 00000000..8320178d --- /dev/null +++ b/src/components/PrivateResourcesBanner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Shield, ArrowRight, Laptop, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +type PrivateResourcesBannerProps = { + orgId: string; +}; + +export const PrivateResourcesBanner = ({ + orgId +}: PrivateResourcesBannerProps) => { + const t = useTranslations(); + + return ( + } + description={t("privateResourcesBannerDescription")} + > + + + + + + + + ); +}; + +export default PrivateResourcesBanner; + diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 9a06de19..630d0800 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -184,22 +184,20 @@ function ProductUpdatesListPopup({
-
- -
+

{t("productUpdateWhatsNew")}

- +
@@ -334,53 +332,50 @@ function NewVersionAvailable({ return ( -
- {version && ( - <> - + + )} ); } diff --git a/src/components/ProxyResourcesBanner.tsx b/src/components/ProxyResourcesBanner.tsx new file mode 100644 index 00000000..40616758 --- /dev/null +++ b/src/components/ProxyResourcesBanner.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { Globe } from "lucide-react"; +import { useTranslations } from "next-intl"; +import DismissableBanner from "./DismissableBanner"; + +export const ProxyResourcesBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("proxyResourcesBannerDescription")} + /> + ); +}; + +export default ProxyResourcesBanner; + diff --git a/src/components/ResetPasswordForm.tsx b/src/components/ResetPasswordForm.tsx index 7afafa0c..7a992add 100644 --- a/src/components/ResetPasswordForm.tsx +++ b/src/components/ResetPasswordForm.tsx @@ -546,6 +546,7 @@ export default function ResetPasswordForm({ )} + + + ); +}; + +export default SitesBanner; + diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 71321bf8..e413207a 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -25,6 +25,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; import { InfoPopup } from "./ui/info-popup"; +import ClientDownloadBanner from "./ClientDownloadBanner"; export type ClientRow = { id: number; @@ -413,6 +414,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { /> )} + + { + const shouldShowRefreshButton = (status: string, updatedAt: number) => { return ( status === "failed" || status === "expired" || (status === "requested" && updatedAt && - new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000) + new Date(updatedAt * 1000).getTime() < + Date.now() - 5 * 60 * 1000) ); }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index c3037250..f530ace1 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -15,7 +15,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ", outline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground ", + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground ", outlinePrimary: "border border-primary bg-card hover:bg-primary/10 text-primary ", secondary: diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 41529692..36dec92b 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -30,7 +30,7 @@ import { TableRow } from "@/components/ui/table"; import { Button } from "@app/components/ui/button"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; import { Plus, Search, RefreshCw, Columns } from "lucide-react"; @@ -236,6 +236,12 @@ export function DataTable({ defaultTab || tabs?.[0]?.id || "" ); + // Track initial values to avoid storing defaults on first render + const initialPageSize = useRef(pageSize); + const initialColumnVisibilityState = useRef(columnVisibility); + const hasUserChangedPageSize = useRef(false); + const hasUserChangedColumnVisibility = useRef(false); + // Apply tab filter to data const filteredData = useMemo(() => { if (!tabs || activeTab === "") { @@ -278,18 +284,26 @@ export function DataTable({ } }); - // Persist pageSize to localStorage when it changes + // Persist pageSize to localStorage when it changes (but not on initial mount) useEffect(() => { if (persistPageSize && pagination.pageSize !== pageSize) { - setStoredPageSize(pagination.pageSize, tableId); + // Only store if user has actually changed it from initial value + if (hasUserChangedPageSize.current && pagination.pageSize !== initialPageSize.current) { + setStoredPageSize(pagination.pageSize, tableId); + } setPageSize(pagination.pageSize); } }, [pagination.pageSize, persistPageSize, tableId, pageSize]); useEffect(() => { - // Persist column visibility to localStorage when it changes + // Persist column visibility to localStorage when it changes (but not on initial mount) if (shouldPersistColumnVisibility) { - setStoredColumnVisibility(columnVisibility, tableId); + const hasChanged = JSON.stringify(columnVisibility) !== JSON.stringify(initialColumnVisibilityState.current); + if (hasChanged) { + // Mark as user-initiated change and persist + hasUserChangedColumnVisibility.current = true; + setStoredColumnVisibility(columnVisibility, tableId); + } } }, [columnVisibility, shouldPersistColumnVisibility, tableId]); @@ -301,6 +315,7 @@ export function DataTable({ // Enhanced pagination component that updates our local state const handlePageSizeChange = (newPageSize: number) => { + hasUserChangedPageSize.current = true; setPagination((prev) => ({ ...prev, pageSize: newPageSize, @@ -308,7 +323,7 @@ export function DataTable({ })); setPageSize(newPageSize); - // Persist immediately when changed + // Persist immediately when user changes it if (persistPageSize) { setStoredPageSize(newPageSize, tableId); } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 0dc44147..e90f6eea 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -228,7 +228,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/users`, { signal }); + >(`/site-resource/${resourceId}/users`, { signal }); return res.data.data.users; } }), @@ -238,7 +238,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/roles`, { signal }); + >(`/site-resource/${resourceId}/roles`, { signal }); return res.data.data.roles; } @@ -249,7 +249,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/clients`, { signal }); + >(`/site-resource/${resourceId}/clients`, { signal }); return res.data.data.clients; }