diff --git a/messages/en-US.json b/messages/en-US.json
index b71eb202..e0728c94 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -51,6 +51,9 @@
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
"siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks",
+ "sitesBannerTitle": "Connect Any Network",
+ "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
+ "sitesBannerButtonText": "Install Site",
"siteCreate": "Create Site",
"siteCreateDescription2": "Follow the steps below to create and connect a new site",
"siteCreateDescription": "Create a new site to start connecting resources",
@@ -147,8 +150,12 @@
"shareErrorSelectResource": "Please select a resource",
"proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
+ "proxyResourcesBannerTitle": "Web-based Public Access",
+ "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
"clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
+ "privateResourcesBannerTitle": "Zero-Trust Private Access",
+ "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
@@ -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",
diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx
index e1a904ad..f2618bc2 100644
--- a/src/app/[orgId]/settings/clients/machine/page.tsx
+++ b/src/app/[orgId]/settings/clients/machine/page.tsx
@@ -1,6 +1,7 @@
import type { ClientRow } from "@app/components/MachineClientsTable";
import MachineClientsTable from "@app/components/MachineClientsTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
+import MachineClientsBanner from "@app/components/MachineClientsBanner";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client";
@@ -71,6 +72,8 @@ export default async function ClientsPage(props: ClientsPageProps) {
description={t("manageMachineClientsDescription")}
/>
+
+
+
+
+
+
+
+
>
);
diff --git a/src/components/ClientDownloadBanner.tsx b/src/components/ClientDownloadBanner.tsx
new file mode 100644
index 00000000..dcd572fd
--- /dev/null
+++ b/src/components/ClientDownloadBanner.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import React from "react";
+import { Button } from "@app/components/ui/button";
+import { Download } from "lucide-react";
+import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import DismissableBanner from "./DismissableBanner";
+
+export const ClientDownloadBanner = () => {
+ const t = useTranslations();
+
+ return (
+ }
+ description={t("downloadClientBannerDescription")}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ClientDownloadBanner;
+
diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx
new file mode 100644
index 00000000..6f49e036
--- /dev/null
+++ b/src/components/DismissableBanner.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+import React, { useState, useEffect, type ReactNode } from "react";
+import { Card, CardContent } from "@app/components/ui/card";
+import { X } from "lucide-react";
+import { useTranslations } from "next-intl";
+
+type DismissableBannerProps = {
+ storageKey: string;
+ version: number;
+ title: string;
+ titleIcon: ReactNode;
+ description: string;
+ children?: ReactNode;
+};
+
+export const DismissableBanner = ({
+ storageKey,
+ version,
+ title,
+ titleIcon,
+ description,
+ children
+}: DismissableBannerProps) => {
+ const [isDismissed, setIsDismissed] = useState(true);
+ const t = useTranslations();
+
+ useEffect(() => {
+ const dismissedData = localStorage.getItem(storageKey);
+ if (dismissedData) {
+ try {
+ const parsed = JSON.parse(dismissedData);
+ // If version matches, use the dismissed state
+ if (parsed.version === version) {
+ setIsDismissed(parsed.dismissed);
+ } else {
+ // Version changed, show the banner again
+ setIsDismissed(false);
+ }
+ } catch {
+ // If parsing fails, check for old format (just "true" string)
+ if (dismissedData === "true") {
+ // Old format, show banner again for new version
+ setIsDismissed(false);
+ } else {
+ setIsDismissed(true);
+ }
+ }
+ } else {
+ setIsDismissed(false);
+ }
+ }, [storageKey, version]);
+
+ const handleDismiss = () => {
+ setIsDismissed(true);
+ localStorage.setItem(
+ storageKey,
+ JSON.stringify({ dismissed: true, version })
+ );
+ };
+
+ if (isDismissed) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {titleIcon}
+ {title}
+
+
+ {description}
+
+
+ {children && (
+
+ {children}
+
+ )}
+
+
+
+ );
+};
+
+export default DismissableBanner;
+
diff --git a/src/components/MachineClientsBanner.tsx b/src/components/MachineClientsBanner.tsx
new file mode 100644
index 00000000..f69fa061
--- /dev/null
+++ b/src/components/MachineClientsBanner.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import React from "react";
+import { Button } from "@app/components/ui/button";
+import { Server, Terminal, Container } from "lucide-react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import DismissableBanner from "./DismissableBanner";
+
+type MachineClientsBannerProps = {
+ orgId: string;
+};
+
+export const MachineClientsBanner = ({
+ orgId
+}: MachineClientsBannerProps) => {
+ const t = useTranslations();
+
+ return (
+ }
+ description={t("machineClientsBannerDescription")}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+export default MachineClientsBanner;
+
diff --git a/src/components/PrivateResourcesBanner.tsx b/src/components/PrivateResourcesBanner.tsx
new file mode 100644
index 00000000..8320178d
--- /dev/null
+++ b/src/components/PrivateResourcesBanner.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import React from "react";
+import { Button } from "@app/components/ui/button";
+import { Shield, ArrowRight, Laptop, Server } from "lucide-react";
+import { useTranslations } from "next-intl";
+import Link from "next/link";
+import DismissableBanner from "./DismissableBanner";
+
+type PrivateResourcesBannerProps = {
+ orgId: string;
+};
+
+export const PrivateResourcesBanner = ({
+ orgId
+}: PrivateResourcesBannerProps) => {
+ const t = useTranslations();
+
+ return (
+ }
+ description={t("privateResourcesBannerDescription")}
+ >
+
+
+
+
+
+
+
+ );
+};
+
+export default PrivateResourcesBanner;
+
diff --git a/src/components/ProxyResourcesBanner.tsx b/src/components/ProxyResourcesBanner.tsx
new file mode 100644
index 00000000..40616758
--- /dev/null
+++ b/src/components/ProxyResourcesBanner.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+import React from "react";
+import { Globe } from "lucide-react";
+import { useTranslations } from "next-intl";
+import DismissableBanner from "./DismissableBanner";
+
+export const ProxyResourcesBanner = () => {
+ const t = useTranslations();
+
+ return (
+ }
+ description={t("proxyResourcesBannerDescription")}
+ />
+ );
+};
+
+export default ProxyResourcesBanner;
+
diff --git a/src/components/SitesBanner.tsx b/src/components/SitesBanner.tsx
new file mode 100644
index 00000000..8ba7a232
--- /dev/null
+++ b/src/components/SitesBanner.tsx
@@ -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 (
+ }
+ description={t("sitesBannerDescription")}
+ >
+
+
+
+
+ );
+};
+
+export default SitesBanner;
+
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx
index 71321bf8..e413207a 100644
--- a/src/components/UserDevicesTable.tsx
+++ b/src/components/UserDevicesTable.tsx
@@ -25,6 +25,7 @@ import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge";
import { InfoPopup } from "./ui/info-popup";
+import ClientDownloadBanner from "./ClientDownloadBanner";
export type ClientRow = {
id: number;
@@ -413,6 +414,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
/>
)}
+
+