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:
interval: 10s test:
retries: 15 - CMD
timeout: 10s - cscli
test: ["CMD", "cscli", "capi", "status"] - lapi
- status
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
labels: labels:
- "traefik.enable=false" # Disable traefik for crowdsec - "traefik.enable=false" # Disable traefik for crowdsec
volumes: volumes:

View File

@@ -44,7 +44,7 @@ http:
crowdsecAppsecUnreachableBlock: true # Block on unreachable crowdsecAppsecUnreachableBlock: true # Block on unreachable
crowdsecAppsecBodyLimit: 10485760 crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiHost: crowdsec:8080 # CrowdSec
crowdsecLapiScheme: http # CrowdSec API scheme crowdsecLapiScheme: http # CrowdSec API scheme
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
@@ -106,4 +106,13 @@ http:
api-service: api-service:
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,50 +173,61 @@ 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,
.from(requestAuditLog) uniquePaths,
.where(baseConditions); uniqueResources
] = await Promise.all([
primaryDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1)
]);
// Get unique actors if (
const uniquePaths = await db uniqueActors.length > DISTINCT_LIMIT ||
.selectDistinct({ uniqueLocations.length > DISTINCT_LIMIT ||
paths: requestAuditLog.path uniqueHosts.length > DISTINCT_LIMIT ||
}) uniquePaths.length > DISTINCT_LIMIT ||
.from(requestAuditLog) uniqueResources.length > DISTINCT_LIMIT
.where(baseConditions); ) {
throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
// Get unique resources with names }
const uniqueResources = await db
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
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={() => {
router.refresh(); // Delay refresh to allow modal to close smoothly
setEditingResource(null); setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}} }}
/> />
)} )}
@@ -341,7 +347,10 @@ export default function ClientResourcesTable({
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {
router.refresh(); // Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}} }}
/> />
</> </>

File diff suppressed because it is too large Load Diff

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;

File diff suppressed because it is too large Load Diff

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,53 +332,50 @@ function NewVersionAvailable({
return ( return (
<Transition show={open}> <Transition show={open}>
<div {version && (
className={cn( <a
"relative z-2", href={version.pangolin.releaseNotes}
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm", target="_blank"
"transition duration-300 ease-in-out", rel="noopener noreferrer"
"data-closed:opacity-0 data-closed:translate-y-full" className={cn(
)} "relative z-2 group cursor-pointer block",
> "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",
{version && ( "transition duration-300 ease-in-out",
<> "data-closed:opacity-0 data-closed:translate-y-full"
<div className="flex items-center gap-2"> )}
<div className="rounded-md bg-muted-foreground/20 p-2"> >
<RocketIcon className="flex-none size-4" /> <div className="flex items-center gap-2">
</div> <RocketIcon className="flex-none size-4 text-primary" />
<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) => {
setOpen(false); e.preventDefault();
onDimiss(); e.stopPropagation();
}} setOpen(false);
> onDimiss();
<XIcon className="size-4 flex-none" /> }}
</button> >
<XIcon className="size-3 flex-none opacity-50" />
</button>
</div>
<div className="flex flex-col gap-0.5">
<small className="text-muted-foreground">
{t("pangolinUpdateAvailableInfo", {
version: version.pangolin.latestVersion
})}
</small>
<div className="inline-flex items-center gap-1 text-xs">
<span>
{t("pangolinUpdateAvailableReleaseNotes")}
</span>
<ArrowRight className="flex-none size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-1" />
</div> </div>
<div className="flex flex-col gap-0.5"> </div>
<small className="text-muted-foreground"> </a>
{t("pangolinUpdateAvailableInfo", { )}
version: version.pangolin.latestVersion
})}
</small>
<a
href={version.pangolin.releaseNotes}
target="_blank"
className="inline-flex items-center gap-1 text-xs font-medium"
>
<span>
{t("pangolinUpdateAvailableReleaseNotes")}
</span>
<ArrowRight className="flex-none size-3" />
</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,18 +284,26 @@ 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) {
setStoredPageSize(pagination.pageSize, tableId); // Only store if user has actually changed it from initial value
if (hasUserChangedPageSize.current && pagination.pageSize !== initialPageSize.current) {
setStoredPageSize(pagination.pageSize, tableId);
}
setPageSize(pagination.pageSize); 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) {
setStoredColumnVisibility(columnVisibility, tableId); const hasChanged = JSON.stringify(columnVisibility) !== JSON.stringify(initialColumnVisibilityState.current);
if (hasChanged) {
// Mark as user-initiated change and persist
hasUserChangedColumnVisibility.current = true;
setStoredColumnVisibility(columnVisibility, tableId);
}
} }
}, [columnVisibility, shouldPersistColumnVisibility, tableId]); }, [columnVisibility, shouldPersistColumnVisibility, tableId]);
@@ -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;
} }