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)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square)
[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@pangolin-net)
</div>

View File

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

View File

@@ -44,7 +44,7 @@ http:
crowdsecAppsecUnreachableBlock: true # Block on unreachable
crowdsecAppsecBodyLimit: 10485760
crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later
crowdsecLapiHost: crowdsec:8080 # CrowdSec
crowdsecLapiHost: crowdsec:8080 # CrowdSec
crowdsecLapiScheme: http # CrowdSec API scheme
forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs
- "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE)
@@ -106,4 +106,13 @@ http:
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000" # API/WebSocket server
- 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?",
"siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network",
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
"sitesBannerButtonText": "Install Site",
"siteCreate": "Create Site",
"siteCreateDescription2": "Follow the steps below to create and connect a new site",
"siteCreateDescription": "Create a new site to start connecting resources",
@@ -147,8 +150,12 @@
"shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"proxyResourcesBannerTitle": "Web-based Public Access",
"proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
"clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access",
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
@@ -158,9 +165,9 @@
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests to the app over HTTPS using a subdomain or base domain.",
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
"resourceRaw": "Raw TCP/UDP Resource",
"resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.",
"resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.",
"resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceSeeAll": "See All Resources",
@@ -946,7 +953,7 @@
"pincodeAuth": "Authenticator Code",
"pincodeSubmit2": "Submit Code",
"passwordResetSubmit": "Request Reset",
"passwordResetAlreadyHaveCode": "Enter Password Reset Code",
"passwordResetAlreadyHaveCode": "Enter Code",
"passwordResetSmtpRequired": "Please contact your administrator",
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
"passwordBack": "Back to Password",
@@ -1944,8 +1951,15 @@
"beta": "Beta",
"manageUserDevices": "User Devices",
"manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources",
"downloadClientBannerTitle": "Download Pangolin Client",
"downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately",
"manageMachineClients": "Manage Machine Clients",
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
"machineClientsBannerTitle": "Servers & Automated Systems",
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Olm Container",
"clientsTableUserClients": "User",
"clientsTableMachineClients": "Machine",
"licenseTableValidUntil": "Valid Until",
@@ -2318,5 +2332,19 @@
"resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details",
"defaultIdentityProvider": "Default Identity Provider"
"defaultIdentityProvider": "Default Identity Provider",
"editInternalResourceDialogNetworkSettings": "Network Settings",
"editInternalResourceDialogAccessPolicy": "Access Policy",
"editInternalResourceDialogAddRoles": "Add Roles",
"editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
"editInternalResourceDialogTcp": "TCP",
"editInternalResourceDialogUdp": "UDP",
"editInternalResourceDialogIcmp": "ICMP",
"editInternalResourceDialogAccessControl": "Access Control",
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535."
}

View File

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

View File

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

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 { NextFunction } from "express";
import { Request, Response } from "express";
@@ -74,12 +74,12 @@ async function query(query: Q) {
);
}
const [all] = await db
const [all] = await primaryDb
.select({ total: count() })
.from(requestAuditLog)
.where(baseConditions);
const [blocked] = await db
const [blocked] = await primaryDb
.select({ total: count() })
.from(requestAuditLog)
.where(and(baseConditions, eq(requestAuditLog.action, false)));
@@ -88,7 +88,9 @@ async function query(query: Q) {
.mapWith(Number)
.as("total");
const requestsPerCountry = await db
const DISTINCT_LIMIT = 500;
const requestsPerCountry = await primaryDb
.selectDistinct({
code: requestAuditLog.location,
count: totalQ
@@ -96,7 +98,16 @@ async function query(query: Q) {
.from(requestAuditLog)
.where(and(baseConditions, not(isNull(requestAuditLog.location))))
.groupBy(requestAuditLog.location)
.orderBy(desc(totalQ));
.orderBy(desc(totalQ))
.limit(DISTINCT_LIMIT+1);
if (requestsPerCountry.length > DISTINCT_LIMIT) {
// throw an error
throw createHttpError(
HttpCode.BAD_REQUEST,
`Too many distinct countries. Please narrow your query.`
);
}
const groupByDayFunction =
driver === "pg"
@@ -106,7 +117,7 @@ async function query(query: Q) {
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
const requestsPerDay = await db
const requestsPerDay = await primaryDb
.select({
day: groupByDayFunction.as("day"),
allowedCount:

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 { NextFunction } from "express";
import { Request, Response } from "express";
@@ -107,7 +107,7 @@ function getWhere(data: Q) {
}
export function queryRequest(data: Q) {
return db
return primaryDb
.select({
id: requestAuditLog.id,
timestamp: requestAuditLog.timestamp,
@@ -143,7 +143,7 @@ export function queryRequest(data: Q) {
}
export function countRequestQuery(data: Q) {
const countQuery = db
const countQuery = primaryDb
.select({ count: count() })
.from(requestAuditLog)
.where(getWhere(data));
@@ -173,50 +173,61 @@ async function queryUniqueFilterAttributes(
eq(requestAuditLog.orgId, orgId)
);
// Get unique actors
const uniqueActors = await db
.selectDistinct({
actor: requestAuditLog.actor
})
.from(requestAuditLog)
.where(baseConditions);
const DISTINCT_LIMIT = 500;
// Get unique locations
const uniqueLocations = await db
.selectDistinct({
locations: requestAuditLog.location
})
.from(requestAuditLog)
.where(baseConditions);
// TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!!
// Get unique actors
const uniqueHosts = await db
.selectDistinct({
hosts: requestAuditLog.host
})
.from(requestAuditLog)
.where(baseConditions);
// Run all queries in parallel
const [
uniqueActors,
uniqueLocations,
uniqueHosts,
uniquePaths,
uniqueResources
] = await Promise.all([
primaryDb
.selectDistinct({ actor: requestAuditLog.actor })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ locations: requestAuditLog.location })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ hosts: requestAuditLog.host })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({ paths: requestAuditLog.path })
.from(requestAuditLog)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1),
primaryDb
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions)
.limit(DISTINCT_LIMIT+1)
]);
// Get unique actors
const uniquePaths = await db
.selectDistinct({
paths: requestAuditLog.path
})
.from(requestAuditLog)
.where(baseConditions);
// Get unique resources with names
const uniqueResources = await db
.selectDistinct({
id: requestAuditLog.resourceId,
name: resources.name
})
.from(requestAuditLog)
.leftJoin(
resources,
eq(requestAuditLog.resourceId, resources.resourceId)
)
.where(baseConditions);
if (
uniqueActors.length > DISTINCT_LIMIT ||
uniqueLocations.length > DISTINCT_LIMIT ||
uniqueHosts.length > DISTINCT_LIMIT ||
uniquePaths.length > DISTINCT_LIMIT ||
uniqueResources.length > DISTINCT_LIMIT
) {
throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range.");
}
return {
actors: uniqueActors
@@ -295,6 +306,12 @@ export async function queryRequestAuditLogs(
});
} catch (error) {
logger.error(error);
// if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message
if (error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range.") {
return next(
createHttpError(HttpCode.BAD_REQUEST, error.message)
);
}
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);

View File

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

View File

@@ -194,11 +194,23 @@ export async function getOlmToken(
.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) => {
return {
publicKey: exitNode.publicKey,
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,
db,
newts,
orgs,
roles,
roleSiteResources,
SiteResource,
@@ -10,7 +11,7 @@ import {
userSiteResources
} from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names";
import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip";
import { getNextAvailableAliasAddress, isIpInCidr, portRangeStringSchema } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -84,8 +85,7 @@ const createSiteResourceSchema = z
if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
// .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.union([z.cidrv4(), z.cidrv6()])
.safeParse(data.destination).success;
return isValidCIDR;
}
@@ -175,6 +175,39 @@ export async function createSiteResource(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found"));
}
if (!org.subnet || !org.utilitySubnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Organization with ID ${orgId} has no subnet or utilitySubnet defined defined`
)
);
}
// Only check if destination is an IP address
const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success;
if (
isIp &&
(isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet))
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IP can not be in the CIDR range of the organization's subnet or utility subnet"
)
);
}
// // check if resource with same protocol and proxy port already exists (only for port mode)
// if (mode === "port" && protocol && proxyPort) {
// const [existingResource] = await db

View File

@@ -5,6 +5,7 @@ import {
clientSiteResourcesAssociationsCache,
db,
newts,
orgs,
roles,
roleSiteResources,
sites,
@@ -24,6 +25,7 @@ import {
generateAliasConfig,
generateRemoteSubnets,
generateSubnetProxyTargets,
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import {
@@ -96,8 +98,7 @@ const updateSiteResourceSchema = z
if (data.mode === "cidr" && data.destination) {
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
// .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.union([z.cidrv4(), z.cidrv6()])
.safeParse(data.destination).success;
return isValidCIDR;
}
@@ -196,6 +197,39 @@ export async function updateSiteResource(
);
}
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, existingSiteResource.orgId))
.limit(1);
if (!org) {
return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found"));
}
if (!org.subnet || !org.utilitySubnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined`
)
);
}
// Only check if destination is an IP address
const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success;
if (
isIp &&
(isIpInCidr(destination!, org.subnet) || isIpInCidr(destination!, org.utilitySubnet))
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"IP can not be in the CIDR range of the organization's subnet or utility subnet"
)
);
}
let existingSite = site;
let siteChanged = false;
if (existingSiteResource.siteId !== siteId) {

View File

@@ -303,6 +303,24 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<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>
<Form {...form}>
<form
@@ -331,24 +349,6 @@ export default function Page() {
</form>
</Form>
</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>
</SettingsSection>

View File

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

View File

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

View File

@@ -1312,6 +1312,35 @@ export default function Page() {
</SettingsSectionTitle>
</SettingsSectionHeader>
<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>
<Form {...baseForm}>
<form
@@ -1348,35 +1377,6 @@ export default function Page() {
</form>
</Form>
</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>
</SettingsSection>
@@ -1684,7 +1684,7 @@ export default function Page() {
</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">
{t("targetNoOne")}
</p>

View File

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

View File

@@ -674,6 +674,26 @@ WantedBy=default.target`
</SettingsSectionTitle>
</SettingsSectionHeader>
<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
onKeyDown={(e) => {
@@ -748,26 +768,6 @@ WantedBy=default.target`
)}
</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>
</SettingsSection>

View File

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

View File

@@ -209,22 +209,22 @@ export default function Page() {
</Form>
</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) => {
form.setValue("type", value as "oidc");
}}
cols={3}
/>
</div>
{/* <div> */}
{/* <div className="mb-2"> */}
{/* <span className="text-sm font-medium"> */}
{/* {t("idpType")} */}
{/* </span> */}
{/* </div> */}
{/* */}
{/* <StrategySelect */}
{/* options={providerTypes} */}
{/* defaultValue={form.getValues("type")} */}
{/* onChange={(value) => { */}
{/* form.setValue("type", value as "oidc"); */}
{/* }} */}
{/* cols={3} */}
{/* /> */}
{/* </div> */}
</SettingsSectionBody>
</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}
enableColumnVisibility={true}
persistColumnVisibility="internal-resources"
columnVisibility={{
niceId: false
}}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
@@ -329,8 +332,11 @@ export default function ClientResourcesTable({
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
setEditingResource(null);
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
setEditingResource(null);
}, 150);
}}
/>
)}
@@ -341,7 +347,10 @@ export default function ClientResourcesTable({
orgId={orgId}
sites={sites}
onSuccess={() => {
router.refresh();
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
router.refresh();
}, 150);
}}
/>
</>

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 { useTranslations } from "next-intl";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
type DashboardLoginFormProps = {
redirect?: string;
@@ -48,9 +49,14 @@ export default function DashboardLoginForm({
? env.branding.logo?.authPage?.height || 58
: 58;
const gradientClasses =
build === "saas"
? "border-b border-primary/30 bg-gradient-to-br dark:from-primary/20 from-primary/20 via-background to-background overflow-hidden rounded-t-lg"
: "border-b";
return (
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<CardHeader className={gradientClasses}>
<div className="flex flex-row items-center justify-center">
<BrandingLogo height={logoHeight} width={logoWidth} />
</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";
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
@@ -20,17 +20,22 @@ interface HorizontalTabsProps {
children: React.ReactNode;
items: TabItem[];
disabled?: boolean;
clientSide?: boolean;
defaultTab?: number;
}
export function HorizontalTabs({
children,
items,
disabled = false
disabled = false,
clientSide = false,
defaultTab = 0
}: HorizontalTabsProps) {
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const t = useTranslations();
const [activeClientTab, setActiveClientTab] = useState(defaultTab);
function hydrateHref(href: string) {
return href
@@ -43,6 +48,73 @@ export function HorizontalTabs({
.replace("{remoteExitNodeId}", params.remoteExitNodeId as string);
}
// Client-side mode: render tabs as buttons with state management
if (clientSide) {
const childrenArray = React.Children.toArray(children);
const activeChild = childrenArray[activeClientTab] || null;
return (
<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 (
<div className="space-y-3">
<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>
<div
className={cn(
"relative z-1 cursor-pointer block",
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm",
"relative z-1 cursor-pointer block group",
"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",
"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">
<BellIcon className="flex-none size-4" />
</div>
<BellIcon className="flex-none size-4 text-primary" />
<div className="flex justify-between items-center flex-1">
<p className="font-medium text-start">
{t("productUpdateWhatsNew")}
</p>
<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>
@@ -334,53 +332,50 @@ function NewVersionAvailable({
return (
<Transition show={open}>
<div
className={cn(
"relative z-2",
"rounded-md border bg-muted p-2 py-3 w-full flex flex-col gap-2 text-sm",
"transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full"
)}
>
{version && (
<>
<div className="flex items-center gap-2">
<div className="rounded-md bg-muted-foreground/20 p-2">
<RocketIcon className="flex-none size-4" />
</div>
<p className="font-medium flex-1">
{t("pangolinUpdateAvailable")}
</p>
<button
className="p-1 cursor-pointer"
onClick={() => {
setOpen(false);
onDimiss();
}}
>
<XIcon className="size-4 flex-none" />
</button>
{version && (
<a
href={version.pangolin.releaseNotes}
target="_blank"
rel="noopener noreferrer"
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",
"transition duration-300 ease-in-out",
"data-closed:opacity-0 data-closed:translate-y-full"
)}
>
<div className="flex items-center gap-2">
<RocketIcon className="flex-none size-4 text-primary" />
<p className="font-medium flex-1">
{t("pangolinUpdateAvailable")}
</p>
<button
className="p-1 cursor-pointer z-10"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
onDimiss();
}}
>
<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 className="flex flex-col gap-0.5">
<small className="text-muted-foreground">
{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>
</div>
</a>
)}
</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
type="button"
variant="outline"
className="w-full"
onClick={() => {
const email =

View File

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

View File

@@ -15,7 +15,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ",
outline:
"border border-input bg-card hover:bg-accent hover:text-accent-foreground ",
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground ",
outlinePrimary:
"border border-primary bg-card hover:bg-primary/10 text-primary ",
secondary:

View File

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

View File

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