mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-01 07:39:09 +00:00
Merge branch 'dev' into feat/device-approvals
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
[](https://pangolin.net/slack)
|
||||
[](https://hub.docker.com/r/fosrl/pangolin)
|
||||

|
||||
[](https://www.youtube.com/@fossorial-app)
|
||||
[](https://www.youtube.com/@pangolin-net)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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] ?? []
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
69
src/components/ClientDownloadBanner.tsx
Normal file
69
src/components/ClientDownloadBanner.tsx
Normal 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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
98
src/components/DismissableBanner.tsx
Normal file
98
src/components/DismissableBanner.tsx
Normal 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
@@ -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">
|
||||
|
||||
60
src/components/MachineClientsBanner.tsx
Normal file
60
src/components/MachineClientsBanner.tsx
Normal 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;
|
||||
|
||||
54
src/components/PrivateResourcesBanner.tsx
Normal file
54
src/components/PrivateResourcesBanner.tsx
Normal 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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/ProxyResourcesBanner.tsx
Normal file
23
src/components/ProxyResourcesBanner.tsx
Normal 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;
|
||||
|
||||
@@ -546,6 +546,7 @@ export default function ResetPasswordForm({
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const email =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
40
src/components/SitesBanner.tsx
Normal file
40
src/components/SitesBanner.tsx
Normal 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;
|
||||
|
||||
@@ -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 || []}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user