+ {title} +
++ {description} +
+-
+ {features.map((feature, idx) => (
+
-
+
+
+ {feature.title} +
++ {feature.description} +
+
+ ))}
+
{footnote}
+ ) : null} +- {feature} -
-- {description} -
-- {h.title} -
-- {h.description} -
-- Available on ShellHub Cloud and Enterprise editions. -
--
{steps.map((step, idx) => (
.
+ expect(
+ screen.getByRole("region", { name: "Web Endpoints" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders the features as a list when provided", () => {
+ render(
+ }
+ overline="o"
+ title="t"
+ description="d"
+ features={features}
+ />,
+ );
+
+ const list = screen.getByRole("list");
+ expect(within(list).getAllByRole("listitem")).toHaveLength(2);
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Direct Access" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Handles TLS locally.")).toBeInTheDocument();
+ });
+
+ it("omits the features list when none are provided", () => {
+ render(
+ } overline="o" title="t" description="d" />,
+ );
+ expect(screen.queryByRole("list")).not.toBeInTheDocument();
+ });
+
+ it("applies the yellow accent to the overline", () => {
+ render(
+ }
+ overline="Vault Locked"
+ title="Locked"
+ description="d"
+ accent="yellow"
+ />,
+ );
+ expect(screen.getByText("Vault Locked")).toHaveClass(
+ "text-accent-yellow/80",
+ );
+ });
+
+ it("defaults to the primary accent", () => {
+ render(
+ }
+ overline="Networking"
+ title="t"
+ description="d"
+ />,
+ );
+ expect(screen.getByText("Networking")).toHaveClass("text-primary/80");
+ });
+});
diff --git a/ui-react/apps/console/src/components/layout/AdminLayout.tsx b/ui-react/apps/console/src/components/layout/AdminLayout.tsx
index 695a0537a3b..1985432d60b 100644
--- a/ui-react/apps/console/src/components/layout/AdminLayout.tsx
+++ b/ui-react/apps/console/src/components/layout/AdminLayout.tsx
@@ -6,11 +6,12 @@ import { useSidebarLayout } from "@/hooks/useSidebarLayout";
export default function AdminLayout() {
const { pathname } = useLocation();
- const { isOpen, pinned, isDesktop, drawerOpen, handlers } = useSidebarLayout();
+ const { isOpen, pinned, isDesktop, drawerOpen, handlers } =
+ useSidebarLayout();
return (
-
+
{isDesktop ? (
-
+
) : (
)}
-
-
-
+
+
+
-
-
-
+
+
diff --git a/ui-react/apps/console/src/components/layout/AppLayout.tsx b/ui-react/apps/console/src/components/layout/AppLayout.tsx
index c69317fd5ac..0bcb3a5284e 100644
--- a/ui-react/apps/console/src/components/layout/AppLayout.tsx
+++ b/ui-react/apps/console/src/components/layout/AppLayout.tsx
@@ -62,21 +62,21 @@ export default function AppLayout() {
/>
)}
-
+
-
+
-
-
-
+
+
diff --git a/ui-react/apps/console/src/pages/BannerEdit.tsx b/ui-react/apps/console/src/pages/BannerEdit.tsx
index f519df86a7c..607de2b0c1c 100644
--- a/ui-react/apps/console/src/pages/BannerEdit.tsx
+++ b/ui-react/apps/console/src/pages/BannerEdit.tsx
@@ -141,7 +141,7 @@ export default function BannerEdit() {
}
return (
-
+
{/* Header */}
+ <>
{/* Content */}
{isLoading && webEndpoints.length === 0 && !isSearching ? (
) : isTrulyEmpty ? (
- /* Empty state */
-
- {/* Background */}
-
-
-
-
-
-
-
-
- {/* Header */}
-
-
-
-
-
-
- HTTP Tunneling
-
-
- Web Endpoints
-
-
- Create unique URLs that tunnel HTTP traffic to services
- running on your devices.
-
-
-
- {/* Highlights */}
-
- {[
- {
- icon: ,
- title: "Direct Access",
- description:
- "Each endpoint gets a unique URL that routes directly to a host and port on your device.",
- },
- {
- icon: ,
- title: "Device-side TLS",
- description:
- "Connect to HTTPS services on your devices. The agent handles the TLS handshake locally.",
- },
- {
- icon: ,
- title: "Auto-Expiring",
- description:
- "Endpoints expire automatically after a configurable TTL, or run indefinitely.",
- },
- ].map((h, idx) => (
-
-
- {h.icon}
-
-
- {h.title}
-
-
- {h.description}
-
-
- ))}
-
-
- {/* CTA */}
-
-
-
-
-
- No VPN, no SSH port forwarding — just a URL.
-
-
-
-
-
+ }
+ overline="HTTP Tunneling"
+ title="Web Endpoints"
+ description="Create unique URLs that tunnel HTTP traffic to services running on your devices."
+ features={[
+ {
+ icon: ,
+ title: "Direct Access",
+ description:
+ "Each endpoint gets a unique URL that routes directly to a host and port on your device.",
+ },
+ {
+ icon: ,
+ title: "Device-side TLS",
+ description:
+ "Connect to HTTPS services on your devices. The agent handles the TLS handshake locally.",
+ },
+ {
+ icon: ,
+ title: "Auto-Expiring",
+ description:
+ "Endpoints expire automatically after a configurable TTL, or run indefinitely.",
+ },
+ ]}
+ footnote="No VPN, no SSH port forwarding — just a URL."
+ >
+
+
+
+
) : (
<>
)}
-
+ >
);
}
diff --git a/ui-react/apps/console/src/pages/admin/Dashboard.tsx b/ui-react/apps/console/src/pages/admin/Dashboard.tsx
index ebeabe167e0..8ac33b460b4 100644
--- a/ui-react/apps/console/src/pages/admin/Dashboard.tsx
+++ b/ui-react/apps/console/src/pages/admin/Dashboard.tsx
@@ -41,7 +41,7 @@ export default function AdminDashboard() {
if (statsError) {
return (
-
+
diff --git a/ui-react/apps/console/src/pages/admin/License.tsx b/ui-react/apps/console/src/pages/admin/License.tsx
index 475e4158c6b..777c50864e3 100644
--- a/ui-react/apps/console/src/pages/admin/License.tsx
+++ b/ui-react/apps/console/src/pages/admin/License.tsx
@@ -377,7 +377,7 @@ export default function AdminLicense() {
if (isError) {
return (
-
+
Failed to load license information
diff --git a/ui-react/apps/console/src/pages/admin/SessionDetails.tsx b/ui-react/apps/console/src/pages/admin/SessionDetails.tsx
index cb336faa376..3848cf9fda2 100644
--- a/ui-react/apps/console/src/pages/admin/SessionDetails.tsx
+++ b/ui-react/apps/console/src/pages/admin/SessionDetails.tsx
@@ -61,7 +61,7 @@ export default function AdminSessionDetails() {
if (error || !session) {
return (
-
+
diff --git a/ui-react/apps/console/src/pages/admin/Unauthorized.tsx b/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
index 8a5900511bf..ee5ac56a32f 100644
--- a/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
+++ b/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
@@ -8,8 +8,11 @@ import {
CpuChipIcon,
} from "@heroicons/react/24/outline";
import { useAuthStore } from "@/stores/authStore";
+import EmptyState, {
+ type EmptyStateFeature,
+} from "@/components/common/EmptyState";
-const highlights = [
+const highlights: EmptyStateFeature[] = [
{
icon: ,
title: "Main Application",
@@ -40,91 +43,36 @@ export default function AdminUnauthorized() {
};
return (
- }
+ overline="Access Restricted"
+ title="Admin Access Required"
+ description="You don't have administrator privileges to access the Admin Console. This area is restricted to system administrators only."
+ features={highlights}
+ footnote="If you believe you should have admin access, contact your system administrator."
>
- {/* Background */}
-
- Web Endpoints -
-- Create unique URLs that tunnel HTTP traffic to services - running on your devices. -
-- {h.title} -
-- {h.description} -
-- No VPN, no SSH port forwarding — just a URL. -
-diff --git a/ui-react/apps/console/src/pages/admin/License.tsx b/ui-react/apps/console/src/pages/admin/License.tsx index 475e4158c6b..777c50864e3 100644 --- a/ui-react/apps/console/src/pages/admin/License.tsx +++ b/ui-react/apps/console/src/pages/admin/License.tsx @@ -377,7 +377,7 @@ export default function AdminLicense() { if (isError) { return ( -
Failed to load license information
diff --git a/ui-react/apps/console/src/pages/admin/SessionDetails.tsx b/ui-react/apps/console/src/pages/admin/SessionDetails.tsx index cb336faa376..3848cf9fda2 100644 --- a/ui-react/apps/console/src/pages/admin/SessionDetails.tsx +++ b/ui-react/apps/console/src/pages/admin/SessionDetails.tsx @@ -61,7 +61,7 @@ export default function AdminSessionDetails() { if (error || !session) { return ( -
diff --git a/ui-react/apps/console/src/pages/admin/Unauthorized.tsx b/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
index 8a5900511bf..ee5ac56a32f 100644
--- a/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
+++ b/ui-react/apps/console/src/pages/admin/Unauthorized.tsx
@@ -8,8 +8,11 @@ import {
CpuChipIcon,
} from "@heroicons/react/24/outline";
import { useAuthStore } from "@/stores/authStore";
+import EmptyState, {
+ type EmptyStateFeature,
+} from "@/components/common/EmptyState";
-const highlights = [
+const highlights: EmptyStateFeature[] = [
{
icon: