Merge branch 'dev' into feat/device-approvals

This commit is contained in:
Fred KISSIE
2025-12-19 20:03:05 +01:00
40 changed files with 2382 additions and 1493 deletions

View File

@@ -31,7 +31,7 @@
[![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack) [![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) [![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) ![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)
</div> </div>

View File

@@ -9,10 +9,15 @@ services:
PARSERS: crowdsecurity/whitelists PARSERS: crowdsecurity/whitelists
ENROLL_TAGS: docker ENROLL_TAGS: docker
healthcheck: healthcheck:
test:
- CMD
- cscli
- lapi
- status
interval: 10s interval: 10s
retries: 15 timeout: 5s
timeout: 10s retries: 3
test: ["CMD", "cscli", "capi", "status"] start_period: 30s
labels: labels:
- "traefik.enable=false" # Disable traefik for crowdsec - "traefik.enable=false" # Disable traefik for crowdsec
volumes: volumes:

View File

@@ -107,3 +107,12 @@ http:
loadBalancer: loadBalancer:
servers: servers:
- url: "http://pangolin:3000" # API/WebSocket server - url: "http://pangolin:3000" # API/WebSocket server
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2

View File

@@ -51,6 +51,9 @@
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
"siteManageSites": "Manage Sites", "siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks", "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", "siteCreate": "Create Site",
"siteCreateDescription2": "Follow the steps below to create and connect a new site", "siteCreateDescription2": "Follow the steps below to create and connect a new site",
"siteCreateDescription": "Create a new site to start connecting resources", "siteCreateDescription": "Create a new site to start connecting resources",
@@ -147,8 +150,12 @@
"shareErrorSelectResource": "Please select a resource", "shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources", "proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "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", "clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "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...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting 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.", "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?", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourceHTTP": "HTTPS Resource", "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", "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", "resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource", "resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceSeeAll": "See All Resources", "resourceSeeAll": "See All Resources",
@@ -946,7 +953,7 @@
"pincodeAuth": "Authenticator Code", "pincodeAuth": "Authenticator Code",
"pincodeSubmit2": "Submit Code", "pincodeSubmit2": "Submit Code",
"passwordResetSubmit": "Request Reset", "passwordResetSubmit": "Request Reset",
"passwordResetAlreadyHaveCode": "Enter Password Reset Code", "passwordResetAlreadyHaveCode": "Enter Code",
"passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequired": "Please contact your administrator",
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
"passwordBack": "Back to Password", "passwordBack": "Back to Password",
@@ -1944,8 +1951,15 @@
"beta": "Beta", "beta": "Beta",
"manageUserDevices": "User Devices", "manageUserDevices": "User Devices",
"manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", "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", "manageMachineClients": "Manage Machine Clients",
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", "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", "clientsTableUserClients": "User",
"clientsTableMachineClients": "Machine", "clientsTableMachineClients": "Machine",
"licenseTableValidUntil": "Valid Until", "licenseTableValidUntil": "Valid Until",
@@ -2318,5 +2332,19 @@
"resourceLoginPageDescription": "Customize the login page for individual resources", "resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation", "enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details", "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."
} }

View File

@@ -81,6 +81,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db.$primary;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];

View File

@@ -20,6 +20,7 @@ function createDb() {
export const db = createDb(); export const db = createDb();
export default db; export default db;
export const primaryDb = db;
export type Transaction = Parameters< export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0] Parameters<(typeof db)["transaction"]>[0]
>[0]; >[0];

View File

@@ -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 { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } 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() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions);
const [blocked] = await db const [blocked] = await primaryDb
.select({ total: count() }) .select({ total: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false))); .where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -88,7 +88,9 @@ async function query(query: Q) {
.mapWith(Number) .mapWith(Number)
.as("total"); .as("total");
const requestsPerCountry = await db const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryDb
.selectDistinct({ .selectDistinct({
code: requestAuditLog.location, code: requestAuditLog.location,
count: totalQ count: totalQ
@@ -96,7 +98,16 @@ async function query(query: Q) {
.from(requestAuditLog) .from(requestAuditLog)
.where(and(baseConditions, not(isNull(requestAuditLog.location)))) .where(and(baseConditions, not(isNull(requestAuditLog.location))))
.groupBy(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 = const groupByDayFunction =
driver === "pg" driver === "pg"
@@ -106,7 +117,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await db const requestsPerDay = await primaryDb
.select({ .select({
day: groupByDayFunction.as("day"), day: groupByDayFunction.as("day"),
allowedCount: allowedCount:

View File

@@ -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 { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
@@ -107,7 +107,7 @@ function getWhere(data: Q) {
} }
export function queryRequest(data: Q) { export function queryRequest(data: Q) {
return db return primaryDb
.select({ .select({
id: requestAuditLog.id, id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp, timestamp: requestAuditLog.timestamp,
@@ -143,7 +143,7 @@ export function queryRequest(data: Q) {
} }
export function countRequestQuery(data: Q) { export function countRequestQuery(data: Q) {
const countQuery = db const countQuery = primaryDb
.select({ count: count() }) .select({ count: count() })
.from(requestAuditLog) .from(requestAuditLog)
.where(getWhere(data)); .where(getWhere(data));
@@ -173,40 +173,39 @@ async function queryUniqueFilterAttributes(
eq(requestAuditLog.orgId, orgId) eq(requestAuditLog.orgId, orgId)
); );
// Get unique actors const DISTINCT_LIMIT = 500;
const uniqueActors = await db
.selectDistinct({
actor: requestAuditLog.actor
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique locations // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!!
const uniqueLocations = await db
.selectDistinct({
locations: requestAuditLog.location
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique actors // Run all queries in parallel
const uniqueHosts = await db const [
.selectDistinct({ uniqueActors,
hosts: requestAuditLog.host uniqueLocations,
}) uniqueHosts,
uniquePaths,
uniqueResources
] = await Promise.all([
primaryDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions)
.limit(DISTINCT_LIMIT+1),
// Get unique actors primaryDb
const uniquePaths = await db .selectDistinct({ locations: requestAuditLog.location })
.selectDistinct({
paths: requestAuditLog.path
})
.from(requestAuditLog) .from(requestAuditLog)
.where(baseConditions); .where(baseConditions)
.limit(DISTINCT_LIMIT+1),
// Get unique resources with names primaryDb
const uniqueResources = await db .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({ .selectDistinct({
id: requestAuditLog.resourceId, id: requestAuditLog.resourceId,
name: resources.name name: resources.name
@@ -216,7 +215,19 @@ async function queryUniqueFilterAttributes(
resources, resources,
eq(requestAuditLog.resourceId, resources.resourceId) eq(requestAuditLog.resourceId, resources.resourceId)
) )
.where(baseConditions); .where(baseConditions)
.limit(DISTINCT_LIMIT+1)
]);
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 { return {
actors: uniqueActors actors: uniqueActors
@@ -295,6 +306,12 @@ export async function queryRequestAuditLogs(
}); });
} catch (error) { } catch (error) {
logger.error(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( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );

View File

@@ -6,8 +6,8 @@ export type GetCertificateResponse = {
status: string; // pending, requested, valid, expired, failed status: string; // pending, requested, valid, expired, failed
expiresAt: string | null; expiresAt: string | null;
lastRenewalAttempt: Date | null; lastRenewalAttempt: Date | null;
createdAt: string; createdAt: number;
updatedAt: string; updatedAt: number;
errorMessage?: string | null; errorMessage?: string | null;
renewalCount: number; renewalCount: number;
}; };

View File

@@ -194,11 +194,23 @@ export async function getOlmToken(
.where(inArray(exitNodes.exitNodeId, exitNodeIds)); .where(inArray(exitNodes.exitNodeId, exitNodeIds));
} }
// Map exitNodeId to siteIds
const exitNodeIdToSiteIds: Record<number, number[]> = {};
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) => { const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return { return {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port, relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: exitNode.endpoint endpoint: exitNode.endpoint,
siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? []
}; };
}); });

View File

@@ -2,6 +2,7 @@ import {
clientSiteResources, clientSiteResources,
db, db,
newts, newts,
orgs,
roles, roles,
roleSiteResources, roleSiteResources,
SiteResource, SiteResource,
@@ -10,7 +11,7 @@ import {
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names"; 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 { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -84,8 +85,7 @@ const createSiteResourceSchema = z
if (data.mode === "cidr") { if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
// .union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
return isValidCIDR; return isValidCIDR;
} }
@@ -175,6 +175,39 @@ export async function createSiteResource(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); 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) // // check if resource with same protocol and proxy port already exists (only for port mode)
// if (mode === "port" && protocol && proxyPort) { // if (mode === "port" && protocol && proxyPort) {
// const [existingResource] = await db // const [existingResource] = await db

View File

@@ -5,6 +5,7 @@ import {
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
db, db,
newts, newts,
orgs,
roles, roles,
roleSiteResources, roleSiteResources,
sites, sites,
@@ -24,6 +25,7 @@ import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargets, generateSubnetProxyTargets,
isIpInCidr,
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
@@ -96,8 +98,7 @@ const updateSiteResourceSchema = z
if (data.mode === "cidr" && data.destination) { if (data.mode === "cidr" && data.destination) {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
// .union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
return isValidCIDR; 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 existingSite = site;
let siteChanged = false; let siteChanged = false;
if (existingSiteResource.siteId !== siteId) { if (existingSiteResource.siteId !== siteId) {

View File

@@ -303,6 +303,24 @@ export default function Page() {
</SettingsSectionDescription> </SettingsSectionDescription>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
@@ -331,24 +349,6 @@ export default function Page() {
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
<div>
<div className="mb-2">
<span className="text-sm font-medium">
{t("idpType")}
</span>
</div>
<StrategySelect
options={providerTypes}
defaultValue={form.getValues("type")}
onChange={(value) => {
handleProviderChange(
value as "oidc" | "google" | "azure"
);
}}
cols={3}
/>
</div>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -1,6 +1,7 @@
import type { ClientRow } from "@app/components/MachineClientsTable"; import type { ClientRow } from "@app/components/MachineClientsTable";
import MachineClientsTable from "@app/components/MachineClientsTable"; import MachineClientsTable from "@app/components/MachineClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import MachineClientsBanner from "@app/components/MachineClientsBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client"; import { ListClientsResponse } from "@server/routers/client";
@@ -71,6 +72,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
description={t("manageMachineClientsDescription")} description={t("manageMachineClientsDescription")}
/> />
<MachineClientsBanner orgId={params.orgId} />
<MachineClientsTable <MachineClientsTable
machineClients={machineClientRows} machineClients={machineClientRows}
orgId={params.orgId} orgId={params.orgId}

View File

@@ -1,6 +1,7 @@
import type { InternalResourceRow } from "@app/components/ClientResourcesTable"; import type { InternalResourceRow } from "@app/components/ClientResourcesTable";
import ClientResourcesTable from "@app/components/ClientResourcesTable"; import ClientResourcesTable from "@app/components/ClientResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrg } from "@app/lib/api/getCachedOrg";
@@ -81,6 +82,8 @@ export default async function ClientResourcesPage(
description={t("clientResourceDescription")} description={t("clientResourceDescription")}
/> />
<PrivateResourcesBanner orgId={params.orgId} />
<OrgProvider org={org}> <OrgProvider org={org}>
<ClientResourcesTable <ClientResourcesTable
internalResources={internalResourceRows} internalResources={internalResourceRows}

View File

@@ -1312,6 +1312,35 @@ export default function Page() {
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{resourceTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</>
)}
<SettingsSectionForm> <SettingsSectionForm>
<Form {...baseForm}> <Form {...baseForm}>
<form <form
@@ -1348,35 +1377,6 @@ export default function Page() {
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
{resourceTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={resourceTypes}
defaultValue="http"
onChange={(value) => {
baseForm.setValue(
"http",
value === "http"
);
// Update method default when switching resource type
addTargetForm.setValue(
"method",
value === "http"
? "http"
: null
);
}}
cols={2}
/>
</>
)}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
@@ -1684,7 +1684,7 @@ export default function Page() {
</div> </div>
</> </>
) : ( ) : (
<div className="text-center p-4"> <div className="text-center py-8 border-2 border-dashed border-muted rounded-lg p-4">
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{t("targetNoOne")} {t("targetNoOne")}
</p> </p>

View File

@@ -1,6 +1,7 @@
import type { ResourceRow } from "@app/components/ProxyResourcesTable"; import type { ResourceRow } from "@app/components/ProxyResourcesTable";
import ProxyResourcesTable from "@app/components/ProxyResourcesTable"; import ProxyResourcesTable from "@app/components/ProxyResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import ProxyResourcesBanner from "@app/components/ProxyResourcesBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
@@ -97,6 +98,8 @@ export default async function ProxyResourcesPage(
description={t("proxyResourceDescription")} description={t("proxyResourceDescription")}
/> />
<ProxyResourcesBanner />
<OrgProvider org={org}> <OrgProvider org={org}>
<ProxyResourcesTable <ProxyResourcesTable
resources={resourceRows} resources={resourceRows}

View File

@@ -674,6 +674,26 @@ WantedBy=default.target`
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
<Form {...form}> <Form {...form}>
<form <form
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -748,26 +768,6 @@ WantedBy=default.target`
)} )}
</form> </form>
</Form> </Form>
{tunnelTypes.length > 1 && (
<>
<div className="mb-2">
<span className="text-sm font-medium">
{t("type")}
</span>
</div>
<StrategySelect
options={tunnelTypes}
defaultValue={form.getValues(
"method"
)}
onChange={(value) => {
form.setValue("method", value);
}}
cols={3}
/>
</>
)}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -4,6 +4,7 @@ import { ListSitesResponse } from "@server/routers/site";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import SitesTable, { SiteRow } from "../../../../components/SitesTable"; import SitesTable, { SiteRow } from "../../../../components/SitesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import SitesBanner from "@app/components/SitesBanner";
import SitesSplashCard from "../../../../components/SitesSplashCard"; import SitesSplashCard from "../../../../components/SitesSplashCard";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -67,6 +68,8 @@ export default async function SitesPage(props: SitesPageProps) {
description={t("siteDescription")} description={t("siteDescription")}
/> />
<SitesBanner />
<SitesTable sites={siteRows} orgId={params.orgId} /> <SitesTable sites={siteRows} orgId={params.orgId} />
</> </>
); );

View File

@@ -209,22 +209,22 @@ export default function Page() {
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>
<div> {/* <div> */}
<div className="mb-2"> {/* <div className="mb-2"> */}
<span className="text-sm font-medium"> {/* <span className="text-sm font-medium"> */}
{t("idpType")} {/* {t("idpType")} */}
</span> {/* </span> */}
</div> {/* </div> */}
{/* */}
<StrategySelect {/* <StrategySelect */}
options={providerTypes} {/* options={providerTypes} */}
defaultValue={form.getValues("type")} {/* defaultValue={form.getValues("type")} */}
onChange={(value) => { {/* onChange={(value) => { */}
form.setValue("type", value as "oidc"); {/* form.setValue("type", value as "oidc"); */}
}} {/* }} */}
cols={3} {/* cols={3} */}
/> {/* /> */}
</div> {/* </div> */}
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -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 (
<DismissableBanner
storageKey="client-download-banner-dismissed"
version={1}
title={t("downloadClientBannerTitle")}
titleIcon={<Download className="w-5 h-5 text-primary" />}
description={t("downloadClientBannerDescription")}
>
<Link
href="https://pangolin.net/downloads/mac"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<FaApple className="w-4 h-4" />
Mac
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/windows"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<FaWindows className="w-4 h-4" />
Windows
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/linux"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<FaLinux className="w-4 h-4" />
Linux
</Button>
</Link>
</DismissableBanner>
);
};
export default ClientDownloadBanner;

View File

@@ -317,6 +317,9 @@ export default function ClientResourcesTable({
defaultSort={defaultSort} defaultSort={defaultSort}
enableColumnVisibility={true} enableColumnVisibility={true}
persistColumnVisibility="internal-resources" persistColumnVisibility="internal-resources"
columnVisibility={{
niceId: false
}}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
/> />
@@ -329,8 +332,11 @@ export default function ClientResourcesTable({
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh(); router.refresh();
setEditingResource(null); setEditingResource(null);
}, 150);
}} }}
/> />
)} )}
@@ -341,7 +347,10 @@ export default function ClientResourcesTable({
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh(); router.refresh();
}, 150);
}} }}
/> />
</> </>

View File

@@ -58,6 +58,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs";
// import { InfoPopup } from "@app/components/ui/info-popup"; // import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format // 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 // Port range string schema for client-side validation
const portRangeStringSchema = z // 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() .string()
.optional() .optional()
.nullable() .nullable()
.refine( .refine((val) => isValidPortRangeString(val), {
(val) => isValidPortRangeString(val), message: getPortRangeValidationMessage(t)
{ });
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.'
}
);
// Helper to determine the port mode from a port range string // Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom"; type PortMode = "all" | "blocked" | "custom";
@@ -161,25 +163,18 @@ export default function CreateInternalResourceDialog({
.string() .string()
.min(1, t("createInternalResourceDialogNameRequired")) .min(1, t("createInternalResourceDialogNameRequired"))
.max(255, t("createInternalResourceDialogNameMaxLength")), .max(255, t("createInternalResourceDialogNameMaxLength")),
// mode: z.enum(["host", "cidr", "port"]),
mode: z.enum(["host", "cidr"]),
destination: z.string().min(1),
siteId: z siteId: z
.int() .int()
.positive(t("createInternalResourceDialogPleaseSelectSite")), .positive(t("createInternalResourceDialogPleaseSelectSite")),
// protocol: z.enum(["tcp", "udp"]), // mode: z.enum(["host", "cidr", "port"]),
// proxyPort: z.int() mode: z.enum(["host", "cidr"]),
// .positive() // protocol: z.enum(["tcp", "udp"]).nullish(),
// .min(1, t("createInternalResourceDialogProxyPortMin")) // proxyPort: z.int().positive().min(1, t("createInternalResourceDialogProxyPortMin")).max(65535, t("createInternalResourceDialogProxyPortMax")).nullish(),
// .max(65535, t("createInternalResourceDialogProxyPortMax")), destination: z.string().min(1),
// destinationPort: z.int() // destinationPort: z.int().positive().min(1, t("createInternalResourceDialogDestinationPortMin")).max(65535, t("createInternalResourceDialogDestinationPortMax")).nullish(),
// .positive()
// .min(1, t("createInternalResourceDialogDestinationPortMin"))
// .max(65535, t("createInternalResourceDialogDestinationPortMax"))
// .nullish(),
alias: z.string().nullish(), alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: portRangeStringSchema, udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
roles: z roles: z
.array( .array(
@@ -453,8 +448,8 @@ export default function CreateInternalResourceDialog({
variant: "default" variant: "default"
}); });
onSuccess?.();
setOpen(false); setOpen(false);
onSuccess?.();
} catch (error) { } catch (error) {
console.error("Error creating internal resource:", error); console.error("Error creating internal resource:", error);
toast({ toast({
@@ -498,7 +493,7 @@ export default function CreateInternalResourceDialog({
return ( return (
<Credenza open={open} onOpenChange={setOpen}> <Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent className="max-w-2xl"> <CredenzaContent className="max-w-3xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle> <CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")} {t("createInternalResourceDialogCreateClientResource")}
@@ -516,14 +511,8 @@ export default function CreateInternalResourceDialog({
className="space-y-6" className="space-y-6"
id="create-internal-resource-form" id="create-internal-resource-form"
> >
{/* Resource Properties Form */} {/* Name and Site - Side by Side */}
<div> <div className="grid grid-cols-2 gap-4">
<h3 className="text-lg font-semibold mb-4">
{t(
"createInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -547,11 +536,7 @@ export default function CreateInternalResourceDialog({
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel> <FormLabel>{t("site")}</FormLabel>
{t(
"site"
)}
</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -566,9 +551,7 @@ export default function CreateInternalResourceDialog({
> >
{field.value {field.value
? availableSites.find( ? availableSites.find(
( (site) =>
site
) =>
site.siteId === site.siteId ===
field.value field.value
)?.name )?.name
@@ -594,9 +577,7 @@ export default function CreateInternalResourceDialog({
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{availableSites.map( {availableSites.map(
( (site) => (
site
) => (
<CommandItem <CommandItem
key={ key={
site.siteId site.siteId
@@ -634,7 +615,59 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
{/* Tabs for Network Settings and Access Control */}
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t(
"editInternalResourceDialogAccessPolicy"
),
href: "#"
}
]}
>
{/* Network Settings Tab */}
<div className="space-y-4 mt-4">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
{/* Mode - Smaller select */}
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="mode" name="mode"
@@ -649,7 +682,9 @@ export default function CreateInternalResourceDialog({
onValueChange={ onValueChange={
field.onChange field.onChange
} }
value={field.value} value={
field.value
}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -657,7 +692,6 @@ export default function CreateInternalResourceDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{/* <SelectItem value="port">{t("createInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host"> <SelectItem value="host">
{t( {t(
"createInternalResourceDialogModeHost" "createInternalResourceDialogModeHost"
@@ -674,76 +708,16 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* </div>
{mode === "port" && (
<>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("createInternalResourceDialogProtocol")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
{t("createInternalResourceDialogTcp")}
</SelectItem>
<SelectItem value="udp">
{t("createInternalResourceDialogUdp")}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField {/* Destination - Larger input */}
control={form.control} <div
name="proxyPort" className={
render={({ field }) => ( mode === "cidr"
<FormItem> ? "col-span-3"
<FormLabel>{t("createInternalResourceDialogSitePort")}</FormLabel> : "col-span-5"
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
} }
/> >
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"createInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="destination" name="destination"
@@ -754,59 +728,20 @@ export default function CreateInternalResourceDialog({
"createInternalResourceDialogDestination" "createInternalResourceDialogDestination"
)} )}
</FormLabel> </FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"createInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"createInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetPort")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" {...field}
value={field.value || ""}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value)
)
}
/> />
</FormControl> </FormControl>
<FormDescription>
{t("createInternalResourceDialogDestinationPortDescription")}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} */}
</div>
</div> </div>
{/* Alias */} {/* Alias - Equally sized input (if allowed) */}
{mode !== "cidr" && ( {mode !== "cidr" && (
<div> <div className="col-span-4">
<FormField <FormField
control={form.control} control={form.control}
name="alias" name="alias"
@@ -821,82 +756,137 @@ export default function CreateInternalResourceDialog({
<Input <Input
{...field} {...field}
value={ value={
field.value ?? "" field.value ??
""
} }
/> />
</FormControl> </FormControl>
<FormDescription>
{t(
"createInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
)} )}
</div>
</div>
{/* Port Restrictions Section */} {/* Ports and Restrictions */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* TCP Ports */} {/* TCP Ports */}
<div className="my-8">
<label className="font-medium block">
{t("portRestrictions")}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogPortRestrictionsDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogTcp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="tcpPortRangeString" name="tcpPortRangeString"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup {/*<InfoPopup
info={t("tcpPortsDescription")} info={t("tcpPortsDescription")}
/>*/} />*/}
<Select <Select
value={tcpPortMode} value={
onValueChange={(value: PortMode) => { tcpPortMode
setTcpPortMode(value); }
onValueChange={(
value: PortMode
) => {
setTcpPortMode(
value
);
}} }}
> >
<FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t("allPorts")} {t(
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t("blocked")} {t(
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t("custom")} {t(
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{tcpPortMode === "custom" ? ( {tcpPortMode ===
"custom" ? (
<FormControl> <FormControl>
<Input <Input
placeholder="80,443,8000-9000" placeholder="80,443,8000-9000"
value={tcpCustomPorts} value={
onChange={(e) => tcpCustomPorts
setTcpCustomPorts(e.target.value) }
onChange={(
e
) =>
setTcpCustomPorts(
e
.target
.value
)
} }
className="flex-1"
/> />
</FormControl> </FormControl>
) : ( ) : (
<Input <Input
disabled disabled
placeholder={ placeholder={
tcpPortMode === "all" tcpPortMode ===
? t("allPortsAllowed") "all"
: t("allPortsBlocked") ? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
} }
className="flex-1"
/> />
)} )}
</div> </div>
@@ -904,61 +894,114 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
</div>
{/* UDP Ports */} {/* UDP Ports */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogUdp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="udpPortRangeString" name="udpPortRangeString"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup {/*<InfoPopup
info={t("udpPortsDescription")} info={t("udpPortsDescription")}
/>*/} />*/}
<Select <Select
value={udpPortMode} value={
onValueChange={(value: PortMode) => { udpPortMode
setUdpPortMode(value); }
onValueChange={(
value: PortMode
) => {
setUdpPortMode(
value
);
}} }}
> >
<FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t("allPorts")} {t(
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t("blocked")} {t(
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t("custom")} {t(
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{udpPortMode === "custom" ? ( {udpPortMode ===
"custom" ? (
<FormControl> <FormControl>
<Input <Input
placeholder="53,123,500-600" placeholder="53,123,500-600"
value={udpCustomPorts} value={
onChange={(e) => udpCustomPorts
setUdpCustomPorts(e.target.value) }
onChange={(
e
) =>
setUdpCustomPorts(
e
.target
.value
)
} }
className="flex-1"
/> />
</FormControl> </FormControl>
) : ( ) : (
<Input <Input
disabled disabled
placeholder={ placeholder={
udpPortMode === "all" udpPortMode ===
? t("allPortsAllowed") "all"
: t("allPortsBlocked") ? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
} }
className="flex-1"
/> />
)} )}
</div> </div>
@@ -966,25 +1009,66 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
</div>
{/* ICMP Toggle */} {/* ICMP Toggle */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogIcmp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="disableIcmp" name="disableIcmp"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={!field.value} checked={
onCheckedChange={(checked) => field.onChange(!checked)} !field.value
}
onCheckedChange={(
checked
) =>
field.onChange(
!checked
)
}
/> />
</FormControl> </FormControl>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")} {field.value
? t(
"blocked"
)
: t(
"allowed"
)}
</span> </span>
</div> </div>
<FormMessage /> <FormMessage />
@@ -993,13 +1077,25 @@ export default function CreateInternalResourceDialog({
/> />
</div> </div>
</div> </div>
</div>
</div>
{/* Access Control Section */} {/* Access Control Tab */}
<div> <div className="space-y-4 mt-4">
<h3 className="text-lg font-semibold mb-4"> <div className="mb-8">
{t("resourceUsersRoles")} <label className="font-medium block">
</h3> {t(
"editInternalResourceDialogAccessControl"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</div>
</div>
<div className="space-y-4"> <div className="space-y-4">
{/* Roles */}
<FormField <FormField
control={form.control} control={form.control}
name="roles" name="roles"
@@ -1023,9 +1119,12 @@ export default function CreateInternalResourceDialog({
size="sm" size="sm"
tags={ tags={
form.getValues() form.getValues()
.roles || [] .roles ||
[]
} }
setTags={(newRoles) => { setTags={(
newRoles
) => {
form.setValue( form.setValue(
"roles", "roles",
newRoles as [ newRoles as [
@@ -1040,7 +1139,9 @@ export default function CreateInternalResourceDialog({
autocompleteOptions={ autocompleteOptions={
allRoles allRoles
} }
allowDuplicates={false} allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
} }
@@ -1056,6 +1157,8 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* Users */}
<FormField <FormField
control={form.control} control={form.control}
name="users" name="users"
@@ -1078,10 +1181,13 @@ export default function CreateInternalResourceDialog({
)} )}
tags={ tags={
form.getValues() form.getValues()
.users || [] .users ||
[]
} }
size="sm" size="sm"
setTags={(newUsers) => { setTags={(
newUsers
) => {
form.setValue( form.setValue(
"users", "users",
newUsers as [ newUsers as [
@@ -1096,7 +1202,9 @@ export default function CreateInternalResourceDialog({
autocompleteOptions={ autocompleteOptions={
allUsers allUsers
} }
allowDuplicates={false} allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
} }
@@ -1107,6 +1215,8 @@ export default function CreateInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* Clients (Machines) */}
{hasMachineClients && ( {hasMachineClients && (
<FormField <FormField
control={form.control} control={form.control}
@@ -1114,7 +1224,9 @@ export default function CreateInternalResourceDialog({
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col items-start"> <FormItem className="flex flex-col items-start">
<FormLabel> <FormLabel>
{t("machineClients")} {t(
"machineClients"
)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<TagInput <TagInput
@@ -1160,7 +1272,9 @@ export default function CreateInternalResourceDialog({
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
} }
sortTags={true} sortTags={
true
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1170,6 +1284,7 @@ export default function CreateInternalResourceDialog({
)} )}
</div> </div>
</div> </div>
</HorizontalTabs>
</form> </form>
</Form> </Form>
</CredenzaBody> </CredenzaBody>

View File

@@ -17,6 +17,7 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import BrandingLogo from "@app/components/BrandingLogo"; import BrandingLogo from "@app/components/BrandingLogo";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
type DashboardLoginFormProps = { type DashboardLoginFormProps = {
redirect?: string; redirect?: string;
@@ -48,9 +49,14 @@ export default function DashboardLoginForm({
? env.branding.logo?.authPage?.height || 58 ? env.branding.logo?.authPage?.height || 58
: 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 ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="border-b"> <CardHeader className={gradientClasses}>
<div className="flex flex-row items-center justify-center"> <div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} /> <BrandingLogo height={logoHeight} width={logoWidth} />
</div> </div>

View File

@@ -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 (
<Card className="mb-6 relative border-primary/30 bg-gradient-to-br from-primary/10 via-background to-background overflow-hidden">
<button
onClick={handleDismiss}
className="absolute top-3 right-3 z-10 p-1.5 rounded-md hover:bg-background/80 transition-colors"
aria-label={t("dismiss")}
>
<X className="w-4 h-4 text-muted-foreground" />
</button>
<CardContent className="p-6">
<div className="flex flex-col lg:flex-row lg:items-center gap-6">
<div className="flex-1 space-y-2 min-w-0">
<h3 className="text-lg font-semibold flex items-center gap-2">
{titleIcon}
{title}
</h3>
<p className="text-sm text-muted-foreground max-w-4xl">
{description}
</p>
</div>
{children && (
<div className="flex flex-wrap gap-3 lg:flex-shrink-0 lg:justify-end">
{children}
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default DismissableBanner;

View File

@@ -56,7 +56,14 @@ import {
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { ListSitesResponse } from "@server/routers/site"; 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"; // import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format // Helper to validate port range string format
@@ -85,7 +92,12 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => {
return false; return false;
} }
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) { if (
startPort < 1 ||
startPort > 65535 ||
endPort < 1 ||
endPort > 65535
) {
return false; return false;
} }
@@ -107,17 +119,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => {
}; };
// Port range string schema for client-side validation // Port range string schema for client-side validation
const portRangeStringSchema = z // 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() .string()
.optional() .optional()
.nullable() .nullable()
.refine( .refine((val) => isValidPortRangeString(val), {
(val) => isValidPortRangeString(val), message: getPortRangeValidationMessage(t)
{ });
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.'
}
);
// Helper to determine the port mode from a port range string // Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom"; 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 // 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 === "all") return "*";
if (mode === "blocked") return ""; if (mode === "blocked") return "";
return customValue; return customValue;
@@ -188,8 +204,8 @@ export default function EditInternalResourceDialog({
destination: z.string().min(1), destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(), alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: createPortRangeStringSchema(t),
udpPortRangeString: portRangeStringSchema, udpPortRangeString: createPortRangeStringSchema(t),
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
roles: z roles: z
.array( .array(
@@ -352,6 +368,9 @@ export default function EditInternalResourceDialog({
number | null number | null
>(null); >(null);
// Collapsible state for ports and restrictions
const [isPortsExpanded, setIsPortsExpanded] = useState(false);
// Port restriction UI state // Port restriction UI state
const [tcpPortMode, setTcpPortMode] = useState<PortMode>( const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
getPortModeFromString(resource.tcpPortRangeString) getPortModeFromString(resource.tcpPortRangeString)
@@ -446,9 +465,7 @@ export default function EditInternalResourceDialog({
} }
// Update the site resource // Update the site resource
await api.post( await api.post(`/site-resource/${resource.id}`, {
`/site-resource/${resource.id}`,
{
name: data.name, name: data.name,
siteId: data.siteId, siteId: data.siteId,
mode: data.mode, mode: data.mode,
@@ -468,8 +485,7 @@ export default function EditInternalResourceDialog({
roleIds: (data.roles || []).map((r) => parseInt(r.id)), roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id), userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id)) clientIds: (data.clients || []).map((c) => parseInt(c.id))
} });
);
// Update roles, users, and clients // Update roles, users, and clients
// await Promise.all([ // await Promise.all([
@@ -502,8 +518,8 @@ export default function EditInternalResourceDialog({
variant: "default" variant: "default"
}); });
onSuccess?.();
setOpen(false); setOpen(false);
onSuccess?.();
} catch (error) { } catch (error) {
console.error("Error updating internal resource:", error); console.error("Error updating internal resource:", error);
toast({ toast({
@@ -543,18 +559,26 @@ export default function EditInternalResourceDialog({
clients: [] clients: []
}); });
// Reset port mode state // Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); setTcpPortMode(
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); getPortModeFromString(resource.tcpPortRangeString)
);
setUdpPortMode(
getPortModeFromString(resource.udpPortRangeString)
);
setTcpCustomPorts( setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" resource.tcpPortRangeString &&
resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString ? resource.tcpPortRangeString
: "" : ""
); );
setUdpCustomPorts( setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*" resource.udpPortRangeString &&
resource.udpPortRangeString !== "*"
? resource.udpPortRangeString ? resource.udpPortRangeString
: "" : ""
); );
// Reset visibility states
setIsPortsExpanded(false);
previousResourceId.current = resource.id; previousResourceId.current = resource.id;
} }
@@ -602,25 +626,33 @@ export default function EditInternalResourceDialog({
clients: [] clients: []
}); });
// Reset port mode state // Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); setTcpPortMode(
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); getPortModeFromString(resource.tcpPortRangeString)
);
setUdpPortMode(
getPortModeFromString(resource.udpPortRangeString)
);
setTcpCustomPorts( setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" resource.tcpPortRangeString &&
resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString ? resource.tcpPortRangeString
: "" : ""
); );
setUdpCustomPorts( setUdpCustomPorts(
resource.udpPortRangeString && 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 // Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null; previousResourceId.current = null;
} }
setOpen(open); setOpen(open);
}} }}
> >
<CredenzaContent className="max-w-2xl"> <CredenzaContent className="max-w-3xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle> <CredenzaTitle>
{t("editInternalResourceDialogEditClientResource")} {t("editInternalResourceDialogEditClientResource")}
@@ -639,14 +671,8 @@ export default function EditInternalResourceDialog({
className="space-y-6" className="space-y-6"
id="edit-internal-resource-form" id="edit-internal-resource-form"
> >
{/* Resource Properties Form */} {/* Name and Site - Side by Side */}
<div> <div className="grid grid-cols-2 gap-4">
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogResourceProperties"
)}
</h3>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -670,11 +696,7 @@ export default function EditInternalResourceDialog({
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel> <FormLabel>{t("site")}</FormLabel>
{t(
"site"
)}
</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -689,9 +711,7 @@ export default function EditInternalResourceDialog({
> >
{field.value {field.value
? availableSites.find( ? availableSites.find(
( (site) =>
site
) =>
site.siteId === site.siteId ===
field.value field.value
)?.name )?.name
@@ -717,9 +737,7 @@ export default function EditInternalResourceDialog({
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
{availableSites.map( {availableSites.map(
( (site) => (
site
) => (
<CommandItem <CommandItem
key={ key={
site.siteId site.siteId
@@ -757,7 +775,59 @@ export default function EditInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
{/* Tabs for Network Settings and Access Control */}
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t(
"editInternalResourceDialogAccessPolicy"
),
href: "#"
}
]}
>
{/* Network Settings Tab */}
<div className="space-y-4 mt-4">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
{/* Mode - Smaller select */}
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="mode" name="mode"
@@ -772,7 +842,9 @@ export default function EditInternalResourceDialog({
onValueChange={ onValueChange={
field.onChange field.onChange
} }
value={field.value} value={
field.value
}
> >
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
@@ -780,7 +852,6 @@ export default function EditInternalResourceDialog({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{/* <SelectItem value="port">{t("editInternalResourceDialogModePort")}</SelectItem> */}
<SelectItem value="host"> <SelectItem value="host">
{t( {t(
"editInternalResourceDialogModeHost" "editInternalResourceDialogModeHost"
@@ -797,64 +868,16 @@ export default function EditInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
{/* {mode === "port" && ( {/* Destination - Larger input */}
<div className="grid grid-cols-2 gap-4"> <div
<FormField className={
control={form.control} mode === "cidr"
name="protocol" ? "col-span-3"
render={({ field }) => ( : "col-span-5"
<FormItem> }
<FormLabel>{t("editInternalResourceDialogProtocol")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? undefined}
> >
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("editInternalResourceDialogSitePort")}</FormLabel>
<FormControl>
<Input
type="number"
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)} */}
</div>
</div>
{/* Target Configuration Form */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t(
"editInternalResourceDialogTargetConfiguration"
)}
</h3>
<div className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="destination" name="destination"
@@ -865,50 +888,20 @@ export default function EditInternalResourceDialog({
"editInternalResourceDialogDestination" "editInternalResourceDialogDestination"
)} )}
</FormLabel> </FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{mode === "host" &&
t(
"editInternalResourceDialogDestinationHostDescription"
)}
{mode === "cidr" &&
t(
"editInternalResourceDialogDestinationCidrDescription"
)}
{/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* {mode === "port" && (
<FormField
control={form.control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t("targetPort")}</FormLabel>
<FormControl> <FormControl>
<Input <Input
type="number" {...field}
value={field.value || ""}
onChange={(e) => field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)} */}
</div>
</div> </div>
{/* Alias */} {/* Alias - Equally sized input (if allowed) */}
{mode !== "cidr" && ( {mode !== "cidr" && (
<div> <div className="col-span-4">
<FormField <FormField
control={form.control} control={form.control}
name="alias" name="alias"
@@ -923,82 +916,137 @@ export default function EditInternalResourceDialog({
<Input <Input
{...field} {...field}
value={ value={
field.value ?? "" field.value ??
""
} }
/> />
</FormControl> </FormControl>
<FormDescription>
{t(
"editInternalResourceDialogAliasDescription"
)}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
)} )}
</div>
</div>
{/* Port Restrictions Section */} {/* Ports and Restrictions */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* TCP Ports */} {/* TCP Ports */}
<div className="my-8">
<label className="font-medium block">
{t("portRestrictions")}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogPortRestrictionsDescription"
)}
</div>
</div>
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogTcp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="tcpPortRangeString" name="tcpPortRangeString"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup {/*<InfoPopup
info={t("tcpPortsDescription")} info={t("tcpPortsDescription")}
/>*/} />*/}
<Select <Select
value={tcpPortMode} value={
onValueChange={(value: PortMode) => { tcpPortMode
setTcpPortMode(value); }
onValueChange={(
value: PortMode
) => {
setTcpPortMode(
value
);
}} }}
> >
<FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t("allPorts")} {t(
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t("blocked")} {t(
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t("custom")} {t(
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{tcpPortMode === "custom" ? ( {tcpPortMode ===
"custom" ? (
<FormControl> <FormControl>
<Input <Input
placeholder="80,443,8000-9000" placeholder="80,443,8000-9000"
value={tcpCustomPorts} value={
onChange={(e) => tcpCustomPorts
setTcpCustomPorts(e.target.value) }
onChange={(
e
) =>
setTcpCustomPorts(
e
.target
.value
)
} }
className="flex-1"
/> />
</FormControl> </FormControl>
) : ( ) : (
<Input <Input
disabled disabled
placeholder={ placeholder={
tcpPortMode === "all" tcpPortMode ===
? t("allPortsAllowed") "all"
: t("allPortsBlocked") ? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
} }
className="flex-1"
/> />
)} )}
</div> </div>
@@ -1006,61 +1054,114 @@ export default function EditInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
</div>
{/* UDP Ports */} {/* UDP Ports */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogUdp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="udpPortRangeString" name="udpPortRangeString"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup {/*<InfoPopup
info={t("udpPortsDescription")} info={t("udpPortsDescription")}
/>*/} />*/}
<Select <Select
value={udpPortMode} value={
onValueChange={(value: PortMode) => { udpPortMode
setUdpPortMode(value); }
onValueChange={(
value: PortMode
) => {
setUdpPortMode(
value
);
}} }}
> >
<FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t("allPorts")} {t(
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t("blocked")} {t(
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t("custom")} {t(
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{udpPortMode === "custom" ? ( {udpPortMode ===
"custom" ? (
<FormControl> <FormControl>
<Input <Input
placeholder="53,123,500-600" placeholder="53,123,500-600"
value={udpCustomPorts} value={
onChange={(e) => udpCustomPorts
setUdpCustomPorts(e.target.value) }
onChange={(
e
) =>
setUdpCustomPorts(
e
.target
.value
)
} }
className="flex-1"
/> />
</FormControl> </FormControl>
) : ( ) : (
<Input <Input
disabled disabled
placeholder={ placeholder={
udpPortMode === "all" udpPortMode ===
? t("allPortsAllowed") "all"
: t("allPortsBlocked") ? t(
"allPortsAllowed"
)
: t(
"allPortsBlocked"
)
} }
className="flex-1"
/> />
)} )}
</div> </div>
@@ -1068,25 +1169,66 @@ export default function EditInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
</div>
</div>
{/* ICMP Toggle */} {/* ICMP Toggle */}
<div
className={cn(
"grid gap-4 items-start",
mode === "cidr"
? "grid-cols-4"
: "grid-cols-12"
)}
>
<div
className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t(
"editInternalResourceDialogIcmp"
)}
</FormLabel>
</div>
<div
className={
mode === "cidr"
? "col-span-3"
: "col-span-9"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="disableIcmp" name="disableIcmp"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl> <FormControl>
<Switch <Switch
checked={!field.value} checked={
onCheckedChange={(checked) => field.onChange(!checked)} !field.value
}
onCheckedChange={(
checked
) =>
field.onChange(
!checked
)
}
/> />
</FormControl> </FormControl>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")} {field.value
? t(
"blocked"
)
: t(
"allowed"
)}
</span> </span>
</div> </div>
<FormMessage /> <FormMessage />
@@ -1095,18 +1237,30 @@ export default function EditInternalResourceDialog({
/> />
</div> </div>
</div> </div>
</div>
</div>
{/* Access Control Section */} {/* Access Control Tab */}
<div> <div className="space-y-4 mt-4">
<h3 className="text-lg font-semibold mb-4"> <div className="mb-8">
{t("resourceUsersRoles")} <label className="font-medium block">
</h3> {t(
"editInternalResourceDialogAccessControl"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</div>
</div>
{loadingRolesUsers ? ( {loadingRolesUsers ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{t("loading")} {t("loading")}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Roles */}
<FormField <FormField
control={form.control} control={form.control}
name="roles" name="roles"
@@ -1130,7 +1284,8 @@ export default function EditInternalResourceDialog({
size="sm" size="sm"
tags={ tags={
form.getValues() form.getValues()
.roles || [] .roles ||
[]
} }
setTags={( setTags={(
newRoles newRoles
@@ -1159,14 +1314,11 @@ export default function EditInternalResourceDialog({
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />
{/* Users */}
<FormField <FormField
control={form.control} control={form.control}
name="users" name="users"
@@ -1189,7 +1341,8 @@ export default function EditInternalResourceDialog({
)} )}
tags={ tags={
form.getValues() form.getValues()
.users || [] .users ||
[]
} }
size="sm" size="sm"
setTags={( setTags={(
@@ -1222,6 +1375,8 @@ export default function EditInternalResourceDialog({
</FormItem> </FormItem>
)} )}
/> />
{/* Clients (Machines) */}
{hasMachineClients && ( {hasMachineClients && (
<FormField <FormField
control={form.control} control={form.control}
@@ -1277,7 +1432,9 @@ export default function EditInternalResourceDialog({
restrictTagsToAutocompleteOptions={ restrictTagsToAutocompleteOptions={
true true
} }
sortTags={true} sortTags={
true
}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -1288,6 +1445,7 @@ export default function EditInternalResourceDialog({
</div> </div>
)} )}
</div> </div>
</HorizontalTabs>
</form> </form>
</Form> </Form>
</CredenzaBody> </CredenzaBody>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, usePathname } from "next/navigation"; import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
@@ -20,17 +20,22 @@ interface HorizontalTabsProps {
children: React.ReactNode; children: React.ReactNode;
items: TabItem[]; items: TabItem[];
disabled?: boolean; disabled?: boolean;
clientSide?: boolean;
defaultTab?: number;
} }
export function HorizontalTabs({ export function HorizontalTabs({
children, children,
items, items,
disabled = false disabled = false,
clientSide = false,
defaultTab = 0
}: HorizontalTabsProps) { }: HorizontalTabsProps) {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations(); const t = useTranslations();
const [activeClientTab, setActiveClientTab] = useState(defaultTab);
function hydrateHref(href: string) { function hydrateHref(href: string) {
return href return href
@@ -43,6 +48,73 @@ export function HorizontalTabs({
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string); .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 (
<div className="space-y-3">
<div className="relative">
<div className="overflow-x-auto scrollbar-hide">
<div className="flex space-x-4 border-b min-w-max">
{items.map((item, index) => {
const isActive = activeClientTab === index;
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled =
disabled ||
(isProfessional && !isUnlocked());
return (
<button
key={index}
type="button"
onClick={() => {
if (!isDisabled) {
setActiveClientTab(index);
}
}}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap relative",
isActive
? "text-primary after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.75 after:bg-primary after:rounded-full"
: "text-muted-foreground hover:text-foreground",
isDisabled && "cursor-not-allowed"
)}
disabled={isDisabled}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div
className={cn(
"flex items-center space-x-2",
isDisabled && "opacity-60"
)}
>
{item.icon && item.icon}
<span>{item.title}</span>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</div>
</button>
);
})}
</div>
</div>
</div>
<div className="space-y-6">{activeChild}</div>
</div>
);
}
// Server-side mode: original behavior with routing
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">

View File

@@ -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 (
<DismissableBanner
storageKey="machine-clients-banner-dismissed"
version={1}
title={t("machineClientsBannerTitle")}
titleIcon={<Server className="w-5 h-5 text-primary" />}
description={t("machineClientsBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#pangolin-cli-linux"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<Terminal className="w-4 h-4" />
{t("machineClientsBannerPangolinCLI")}
</Button>
</Link>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#docker"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<Container className="w-4 h-4" />
{t("machineClientsBannerOlmContainer")}
</Button>
</Link>
</DismissableBanner>
);
};
export default MachineClientsBanner;

View File

@@ -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 (
<DismissableBanner
storageKey="private-resources-banner-dismissed"
version={1}
title={t("privateResourcesBannerTitle")}
titleIcon={<Shield className="w-5 h-5 text-primary" />}
description={t("privateResourcesBannerDescription")}
>
<Link href={`/${orgId}/settings/clients/user`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<Laptop className="w-4 h-4" />
{t("sidebarUserDevices")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
<Link href={`/${orgId}/settings/clients/machine`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<Server className="w-4 h-4" />
{t("sidebarMachineClients")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default PrivateResourcesBanner;

View File

@@ -184,22 +184,20 @@ function ProductUpdatesListPopup({
<PopoverTrigger asChild> <PopoverTrigger asChild>
<div <div
className={cn( className={cn(
"relative z-1 cursor-pointer block", "relative z-1 cursor-pointer block group",
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm", "rounded-md border border-primary/30 bg-gradient-to-br dark:from-primary/20 from-primary/20 via-background to-background p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full" "data-closed:opacity-0 data-closed:translate-y-full"
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="rounded-md bg-muted-foreground/20 p-2"> <BellIcon className="flex-none size-4 text-primary" />
<BellIcon className="flex-none size-4" />
</div>
<div className="flex justify-between items-center flex-1"> <div className="flex justify-between items-center flex-1">
<p className="font-medium text-start"> <p className="font-medium text-start">
{t("productUpdateWhatsNew")} {t("productUpdateWhatsNew")}
</p> </p>
<div className="p-1 cursor-pointer"> <div className="p-1 cursor-pointer">
<ChevronRightIcon className="size-4 flex-none opacity-50" /> <ChevronRightIcon className="size-3.5 flex-none opacity-50 transition-transform duration-300 ease-in-out group-hover:translate-x-1" />
</div> </div>
</div> </div>
</div> </div>
@@ -334,31 +332,33 @@ function NewVersionAvailable({
return ( return (
<Transition show={open}> <Transition show={open}>
<div {version && (
<a
href={version.pangolin.releaseNotes}
target="_blank"
rel="noopener noreferrer"
className={cn( className={cn(
"relative z-2", "relative z-2 group cursor-pointer block",
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm", "rounded-md border border-primary/30 bg-gradient-to-br dark:from-primary/20 from-primary/20 via-background to-background p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full" "data-closed:opacity-0 data-closed:translate-y-full"
)} )}
> >
{version && (
<>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="rounded-md bg-muted-foreground/20 p-2"> <RocketIcon className="flex-none size-4 text-primary" />
<RocketIcon className="flex-none size-4" />
</div>
<p className="font-medium flex-1"> <p className="font-medium flex-1">
{t("pangolinUpdateAvailable")} {t("pangolinUpdateAvailable")}
</p> </p>
<button <button
className="p-1 cursor-pointer" className="p-1 cursor-pointer z-10"
onClick={() => { onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(false); setOpen(false);
onDimiss(); onDimiss();
}} }}
> >
<XIcon className="size-4 flex-none" /> <XIcon className="size-3 flex-none opacity-50" />
</button> </button>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
@@ -367,20 +367,15 @@ function NewVersionAvailable({
version: version.pangolin.latestVersion version: version.pangolin.latestVersion
})} })}
</small> </small>
<a <div className="inline-flex items-center gap-1 text-xs">
href={version.pangolin.releaseNotes}
target="_blank"
className="inline-flex items-center gap-1 text-xs font-medium"
>
<span> <span>
{t("pangolinUpdateAvailableReleaseNotes")} {t("pangolinUpdateAvailableReleaseNotes")}
</span> </span>
<ArrowRight className="flex-none size-3" /> <ArrowRight className="flex-none size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-1" />
</div>
</div>
</a> </a>
</div>
</>
)} )}
</div>
</Transition> </Transition>
); );
} }

View File

@@ -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 (
<DismissableBanner
storageKey="proxy-resources-banner-dismissed"
version={1}
title={t("proxyResourcesBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("proxyResourcesBannerDescription")}
/>
);
};
export default ProxyResourcesBanner;

View File

@@ -546,6 +546,7 @@ export default function ResetPasswordForm({
)} )}
<Button <Button
type="button" type="button"
variant="outline"
className="w-full" className="w-full"
onClick={() => { onClick={() => {
const email = const email =

View File

@@ -28,7 +28,7 @@ export function SettingsSectionForm({
className?: string; className?: string;
}) { }) {
return ( return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div> <div className={cn("md:max-w-1/2 space-y-4", className)}>{children}</div>
); );
} }

View File

@@ -24,7 +24,7 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger PopoverTrigger
} from "@app/components/ui/popover"; } from "@app/components/ui/popover";
import { ChevronDown } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { build } from "@server/build"; import { build } from "@server/build";
export type SidebarNavItem = { export type SidebarNavItem = {
@@ -51,6 +51,7 @@ export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
type CollapsibleNavItemProps = { type CollapsibleNavItemProps = {
item: SidebarNavItem; item: SidebarNavItem;
level: number; level: number;
isActive: boolean;
isChildActive: boolean; isChildActive: boolean;
isDisabled: boolean; isDisabled: boolean;
isCollapsed: boolean; isCollapsed: boolean;
@@ -63,6 +64,7 @@ type CollapsibleNavItemProps = {
function CollapsibleNavItem({ function CollapsibleNavItem({
item, item,
level, level,
isActive,
isChildActive, isChildActive,
isDisabled, isDisabled,
isCollapsed, isCollapsed,
@@ -112,30 +114,30 @@ function CollapsibleNavItem({
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
className={cn( className={cn(
"flex items-center w-full rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md", "flex items-center w-full rounded-md transition-colors",
level === 0 ? "p-3 py-1.5" : "py-1.5", level === 0 ? "px-3 py-2" : "px-3 py-1.5",
isChildActive isActive
? "text-primary font-medium" ? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
disabled={isDisabled} disabled={isDisabled}
> >
{item.icon && ( {item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">{item.icon}</span>
)} )}
<div className="flex items-center gap-1.5 flex-1"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span className="text-left">{t(item.title)}</span> <span className="text-left truncate">{t(item.title)}</span>
{item.isBeta && ( {item.isBeta && (
<Badge <Badge
variant="outline" variant="outline"
className="text-muted-foreground" className="text-muted-foreground flex-shrink-0"
> >
{t("beta")} {t("beta")}
</Badge> </Badge>
)} )}
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
{build === "enterprise" && {build === "enterprise" &&
item.showEE && item.showEE &&
!isUnlocked() && ( !isUnlocked() && (
@@ -143,10 +145,10 @@ function CollapsibleNavItem({
{t("licenseBadge")} {t("licenseBadge")}
</Badge> </Badge>
)} )}
<ChevronDown <ChevronRight
className={cn( className={cn(
"h-4 w-4 transition-transform duration-300 ease-in-out", "h-4 w-4 transition-transform duration-300 ease-in-out text-muted-foreground",
"group-data-[state=open]/collapsible:rotate-180" "group-data-[state=open]/collapsible:rotate-90"
)} )}
/> />
</div> </div>
@@ -155,7 +157,7 @@ function CollapsibleNavItem({
<CollapsibleContent> <CollapsibleContent>
<div <div
className={cn( className={cn(
"border-l ml-3 pl-2 mt-1 space-y-1", "border-l ml-3 pl-3 mt-0 space-y-0",
"border-border" "border-border"
)} )}
> >
@@ -236,6 +238,7 @@ export function SidebarNav({
key={item.title} key={item.title}
item={item} item={item}
level={level} level={level}
isActive={isActive}
isChildActive={isChildActive} isChildActive={isChildActive}
isDisabled={isDisabled || false} isDisabled={isDisabled || false}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
@@ -252,11 +255,11 @@ export function SidebarNav({
<Link <Link
href={isDisabled ? "#" : hydratedHref} href={isDisabled ? "#" : hydratedHref}
className={cn( className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md", "flex items-center rounded-md transition-colors",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isCollapsed ? "px-2 py-2 justify-center" : level === 0 ? "px-3 py-2" : "px-3 py-1.5",
isActive isActive
? "text-primary font-medium" ? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
onClick={(e) => { onClick={(e) => {
@@ -271,19 +274,22 @@ export function SidebarNav({
> >
{item.icon && ( {item.icon && (
<span <span
className={cn("flex-shrink-0", !isCollapsed && "mr-2")} className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center",
!isCollapsed && "mr-3"
)}
> >
{item.icon} {item.icon}
</span> </span>
)} )}
{!isCollapsed && ( {!isCollapsed && (
<> <>
<div className="flex items-center gap-1.5 flex-1"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span>{t(item.title)}</span> <span className="truncate">{t(item.title)}</span>
{item.isBeta && ( {item.isBeta && (
<Badge <Badge
variant="outline" variant="outline"
className="text-muted-foreground" className="text-muted-foreground flex-shrink-0"
> >
{t("beta")} {t("beta")}
</Badge> </Badge>
@@ -292,7 +298,7 @@ export function SidebarNav({
{build === "enterprise" && {build === "enterprise" &&
item.showEE && item.showEE &&
!isUnlocked() && ( !isUnlocked() && (
<Badge variant="outlinePrimary"> <Badge variant="outlinePrimary" className="flex-shrink-0">
{t("licenseBadge")} {t("licenseBadge")}
</Badge> </Badge>
)} )}
@@ -302,27 +308,28 @@ export function SidebarNav({
) : ( ) : (
<div <div
className={cn( className={cn(
"flex items-center rounded transition-colors px-3 py-1.5", "flex items-center rounded-md transition-colors",
level === 0 ? "px-3 py-2" : "px-3 py-1.5",
"text-muted-foreground", "text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60" isDisabled && "cursor-not-allowed opacity-60"
)} )}
> >
{item.icon && ( {item.icon && (
<span className="flex-shrink-0 mr-2">{item.icon}</span> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">{item.icon}</span>
)} )}
<div className="flex items-center gap-1.5 flex-1"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span>{t(item.title)}</span> <span className="truncate">{t(item.title)}</span>
{item.isBeta && ( {item.isBeta && (
<Badge <Badge
variant="outline" variant="outline"
className="text-muted-foreground" className="text-muted-foreground flex-shrink-0"
> >
{t("beta")} {t("beta")}
</Badge> </Badge>
)} )}
</div> </div>
{build === "enterprise" && item.showEE && !isUnlocked() && ( {build === "enterprise" && item.showEE && !isUnlocked() && (
<Badge variant="outlinePrimary">{t("licenseBadge")}</Badge> <Badge variant="outlinePrimary" className="flex-shrink-0 ml-2">{t("licenseBadge")}</Badge>
)} )}
</div> </div>
); );
@@ -338,17 +345,17 @@ export function SidebarNav({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className={cn( className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md px-2 py-2 justify-center w-full", "flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isChildActive isActive || isChildActive
? "text-primary font-medium" ? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled && isDisabled &&
"cursor-not-allowed opacity-60" "cursor-not-allowed opacity-60"
)} )}
disabled={isDisabled} disabled={isDisabled}
> >
{item.icon && ( {item.icon && (
<span className="flex-shrink-0"> <span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{item.icon} {item.icon}
</span> </span>
)} )}
@@ -393,7 +400,7 @@ export function SidebarNav({
: childHydratedHref : childHydratedHref
} }
className={cn( className={cn(
"flex items-center rounded transition-colors px-3 py-1.5 text-sm", "flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive childIsActive
? "bg-secondary text-primary font-medium" ? "bg-secondary text-primary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground", : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
@@ -411,18 +418,18 @@ export function SidebarNav({
}} }}
> >
{childItem.icon && ( {childItem.icon && (
<span className="flex-shrink-0 mr-2"> <span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{childItem.icon} {childItem.icon}
</span> </span>
)} )}
<div className="flex items-center gap-1.5 flex-1"> <div className="flex items-center gap-1.5 flex-1 min-w-0">
<span> <span className="truncate">
{t(childItem.title)} {t(childItem.title)}
</span> </span>
{childItem.isBeta && ( {childItem.isBeta && (
<Badge <Badge
variant="outline" variant="outline"
className="text-muted-foreground" className="text-muted-foreground flex-shrink-0"
> >
{t("beta")} {t("beta")}
</Badge> </Badge>
@@ -431,7 +438,7 @@ export function SidebarNav({
{build === "enterprise" && {build === "enterprise" &&
childItem.showEE && childItem.showEE &&
!isUnlocked() && ( !isUnlocked() && (
<Badge variant="outlinePrimary"> <Badge variant="outlinePrimary" className="flex-shrink-0 ml-2">
{t( {t(
"licenseBadge" "licenseBadge"
)} )}
@@ -467,20 +474,20 @@ export function SidebarNav({
return ( return (
<nav <nav
className={cn( className={cn(
"flex flex-col gap-2 text-sm", "flex flex-col text-sm",
disabled && "pointer-events-none opacity-60", disabled && "pointer-events-none opacity-60",
className className
)} )}
{...props} {...props}
> >
{sections.map((section) => ( {sections.map((section, sectionIndex) => (
<div key={section.heading} className="mb-2"> <div key={section.heading} className={cn(sectionIndex > 0 && "mt-4")}>
{!isCollapsed && ( {!isCollapsed && (
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground/70 uppercase tracking-wide"> <div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider">
{t(`${section.heading}`)} {t(`${section.heading}`)}
</div> </div>
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0">
{section.items.map((item) => renderNavItem(item, 0))} {section.items.map((item) => renderNavItem(item, 0))}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,40 @@
"use client";
import React from "react";
import { Button } from "@app/components/ui/button";
import { Plug, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
export const SitesBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="sites-banner-dismissed"
version={1}
title={t("sitesBannerTitle")}
titleIcon={<Plug className="w-5 h-5 text-primary" />}
description={t("sitesBannerDescription")}
>
<Link
href="https://docs.pangolin.net/manage/sites/install-site"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
{t("sitesBannerButtonText")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default SitesBanner;

View File

@@ -25,6 +25,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup"; import { InfoPopup } from "./ui/info-popup";
import ClientDownloadBanner from "./ClientDownloadBanner";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -413,6 +414,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
/> />
)} )}
<ClientDownloadBanner />
<DataTable <DataTable
columns={columns} columns={columns}
data={userClients || []} data={userClients || []}

View File

@@ -59,13 +59,14 @@ export default function CertificateStatus({
} }
}; };
const shouldShowRefreshButton = (status: string, updatedAt: string) => { const shouldShowRefreshButton = (status: string, updatedAt: number) => {
return ( return (
status === "failed" || status === "failed" ||
status === "expired" || status === "expired" ||
(status === "requested" && (status === "requested" &&
updatedAt && updatedAt &&
new Date(updatedAt).getTime() < Date.now() - 5 * 60 * 1000) new Date(updatedAt * 1000).getTime() <
Date.now() - 5 * 60 * 1000)
); );
}; };

View File

@@ -15,7 +15,7 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ", "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ",
outline: 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: outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary ", "border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary: secondary:

View File

@@ -30,7 +30,7 @@ import {
TableRow TableRow
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@app/components/ui/button"; 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 { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search, RefreshCw, Columns } from "lucide-react"; import { Plus, Search, RefreshCw, Columns } from "lucide-react";
@@ -236,6 +236,12 @@ export function DataTable<TData, TValue>({
defaultTab || tabs?.[0]?.id || "" 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 // Apply tab filter to data
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
if (!tabs || activeTab === "") { if (!tabs || activeTab === "") {
@@ -278,19 +284,27 @@ export function DataTable<TData, TValue>({
} }
}); });
// Persist pageSize to localStorage when it changes // Persist pageSize to localStorage when it changes (but not on initial mount)
useEffect(() => { useEffect(() => {
if (persistPageSize && pagination.pageSize !== pageSize) { if (persistPageSize && pagination.pageSize !== pageSize) {
// Only store if user has actually changed it from initial value
if (hasUserChangedPageSize.current && pagination.pageSize !== initialPageSize.current) {
setStoredPageSize(pagination.pageSize, tableId); setStoredPageSize(pagination.pageSize, tableId);
}
setPageSize(pagination.pageSize); setPageSize(pagination.pageSize);
} }
}, [pagination.pageSize, persistPageSize, tableId, pageSize]); }, [pagination.pageSize, persistPageSize, tableId, pageSize]);
useEffect(() => { useEffect(() => {
// Persist column visibility to localStorage when it changes // Persist column visibility to localStorage when it changes (but not on initial mount)
if (shouldPersistColumnVisibility) { if (shouldPersistColumnVisibility) {
const hasChanged = JSON.stringify(columnVisibility) !== JSON.stringify(initialColumnVisibilityState.current);
if (hasChanged) {
// Mark as user-initiated change and persist
hasUserChangedColumnVisibility.current = true;
setStoredColumnVisibility(columnVisibility, tableId); setStoredColumnVisibility(columnVisibility, tableId);
} }
}
}, [columnVisibility, shouldPersistColumnVisibility, tableId]); }, [columnVisibility, shouldPersistColumnVisibility, tableId]);
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
@@ -301,6 +315,7 @@ export function DataTable<TData, TValue>({
// Enhanced pagination component that updates our local state // Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
hasUserChangedPageSize.current = true;
setPagination((prev) => ({ setPagination((prev) => ({
...prev, ...prev,
pageSize: newPageSize, pageSize: newPageSize,
@@ -308,7 +323,7 @@ export function DataTable<TData, TValue>({
})); }));
setPageSize(newPageSize); setPageSize(newPageSize);
// Persist immediately when changed // Persist immediately when user changes it
if (persistPageSize) { if (persistPageSize) {
setStoredPageSize(newPageSize, tableId); setStoredPageSize(newPageSize, tableId);
} }

View File

@@ -228,7 +228,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSiteResourceUsersResponse> AxiosResponse<ListSiteResourceUsersResponse>
>(`/resource/${resourceId}/users`, { signal }); >(`/site-resource/${resourceId}/users`, { signal });
return res.data.data.users; return res.data.data.users;
} }
}), }),
@@ -238,7 +238,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSiteResourceRolesResponse> AxiosResponse<ListSiteResourceRolesResponse>
>(`/resource/${resourceId}/roles`, { signal }); >(`/site-resource/${resourceId}/roles`, { signal });
return res.data.data.roles; return res.data.data.roles;
} }
@@ -249,7 +249,7 @@ export const resourceQueries = {
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<ListSiteResourceClientsResponse> AxiosResponse<ListSiteResourceClientsResponse>
>(`/resource/${resourceId}/clients`, { signal }); >(`/site-resource/${resourceId}/clients`, { signal });
return res.data.data.clients; return res.data.data.clients;
} }