diff --git a/ui-react/apps/console/src/components/common/EmptyState.tsx b/ui-react/apps/console/src/components/common/EmptyState.tsx new file mode 100644 index 00000000000..62dec03712c --- /dev/null +++ b/ui-react/apps/console/src/components/common/EmptyState.tsx @@ -0,0 +1,165 @@ +import { ReactNode, useId } from "react"; + +export type EmptyStateAccent = "primary" | "yellow"; + +export interface EmptyStateFeature { + /** Sized but uncolored heroicon, e.g. ``. */ + icon: ReactNode; + title: string; + description: string; +} + +export interface EmptyStateProps { + /** Sized but uncolored heroicon, e.g. ``. */ + icon: ReactNode; + overline: string; + title: string; + description: string; + accent?: EmptyStateAccent; + features?: EmptyStateFeature[]; + /** Small muted text rendered under the call-to-action. */ + footnote?: ReactNode; + /** Call-to-action slot — button(s), links, RestrictedAction, etc. */ + children?: ReactNode; +} + +interface AccentStyles { + badge: string; + icon: string; + overline: string; + orbPrimary: string; + orbSecondary: string; +} + +/** + * Accent styles. Full literal class strings (never interpolated fragments) so + * the Tailwind JIT keeps them. The hero icon inherits the badge's text color + * via `currentColor`; feature-card icons stay primary-accented in all variants. + * Typed as `Record` so adding an accent without a matching + * entry is a compile error rather than a runtime `undefined`. + */ +const ACCENT = { + primary: { + badge: "bg-primary/10 border-primary/20 shadow-primary/5", + icon: "text-primary", + overline: "text-primary/80", + orbPrimary: "bg-primary/5", + orbSecondary: "bg-accent-blue/5", + }, + yellow: { + badge: "bg-accent-yellow/10 border-accent-yellow/20 shadow-accent-yellow/5", + icon: "text-accent-yellow", + overline: "text-accent-yellow/80", + orbPrimary: "bg-accent-yellow/5", + orbSecondary: "bg-primary/5", + }, +} satisfies Record; + +/** + * Full-page onboarding / empty / gated-feature splash: a centered card over a + * full-bleed decorative background. Owns the full-bleed layout so call sites + * only declare content. Render it as the sole content of a page (inside the + * AppLayout/AdminLayout `
`). + */ +export default function EmptyState({ + icon, + overline, + title, + description, + accent = "primary", + features, + footnote, + children, +}: EmptyStateProps) { + const headingId = useId(); + const styles = ACCENT[accent]; + const hasFeatures = !!features?.length; + + return ( +
+ {/* Decorative background — bleeds past the main padding (p-8 pb-4) */} +
+ ); +} diff --git a/ui-react/apps/console/src/components/common/FeatureGate.tsx b/ui-react/apps/console/src/components/common/FeatureGate.tsx index c22e3f7e0a4..d6fd6e37c80 100644 --- a/ui-react/apps/console/src/components/common/FeatureGate.tsx +++ b/ui-react/apps/console/src/components/common/FeatureGate.tsx @@ -4,18 +4,15 @@ import { ArrowTopRightOnSquareIcon, } from "@heroicons/react/24/outline"; import { getConfig } from "@/env"; - -interface Highlight { - icon: ReactNode; - title: string; - description: string; -} +import EmptyState, { + type EmptyStateFeature, +} from "@/components/common/EmptyState"; interface FeatureGateProps { children: ReactNode; feature: string; description: string; - highlights?: Highlight[]; + highlights?: EmptyStateFeature[]; } export default function FeatureGate({ @@ -29,79 +26,24 @@ export default function FeatureGate({ } return ( -
- {/* Background */} -
-
-
-
-
- -
-
- {/* Header */} -
-
- -
- - - Premium Feature - -

- {feature} -

-

- {description} -

-
- - {/* Highlights */} - {highlights && highlights.length > 0 && ( -
- {highlights.map((h, idx) => ( -
-
- {h.icon} -
-

- {h.title} -

-

- {h.description} -

-
- ))} -
- )} - - {/* CTA */} -
- - Pricing - - -

- Available on ShellHub Cloud and Enterprise editions. -

-
-
-
-
+ } + overline="Premium Feature" + title={feature} + description={description} + features={highlights} + footnote="Available on ShellHub Cloud and Enterprise editions." + > + + Pricing + + + ); } diff --git a/ui-react/apps/console/src/components/common/PageLoader.tsx b/ui-react/apps/console/src/components/common/PageLoader.tsx index c741dfd7d53..aa6d9caac92 100644 --- a/ui-react/apps/console/src/components/common/PageLoader.tsx +++ b/ui-react/apps/console/src/components/common/PageLoader.tsx @@ -34,7 +34,7 @@ export default function PageLoader({ padding = "md", }: PageLoaderProps) { const resolvedSize = size ?? (showLabel ? "md" : "lg"); - const wrapper = ["flex items-center justify-center", PADDING[padding]] + const wrapper = ["flex h-full items-center justify-center", PADDING[padding]] .filter(Boolean) .join(" "); diff --git a/ui-react/apps/console/src/components/common/WelcomeScreen.tsx b/ui-react/apps/console/src/components/common/WelcomeScreen.tsx index 09fa364ea47..0b7635b13b8 100644 --- a/ui-react/apps/console/src/components/common/WelcomeScreen.tsx +++ b/ui-react/apps/console/src/components/common/WelcomeScreen.tsx @@ -123,9 +123,9 @@ const steps = [ export default function WelcomeScreen({ namespaceName }: WelcomeScreenProps) { return ( -
+
{/* Hero */} -
+
@@ -174,7 +174,7 @@ export default function WelcomeScreen({ namespaceName }: WelcomeScreenProps) {
{/* Steps */} -
+ <>
    {steps.map((step, idx) => (
-
+
); } diff --git a/ui-react/apps/console/src/components/common/__tests__/EmptyState.test.tsx b/ui-react/apps/console/src/components/common/__tests__/EmptyState.test.tsx new file mode 100644 index 00000000000..89f19da91a4 --- /dev/null +++ b/ui-react/apps/console/src/components/common/__tests__/EmptyState.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import EmptyState from "@/components/common/EmptyState"; + +const features = [ + { + icon: , + title: "Direct Access", + description: "Routes directly.", + }, + { + icon: , + title: "Device-side TLS", + description: "Handles TLS locally.", + }, +]; + +describe("EmptyState", () => { + it("renders the overline, title, description, children and footnote", () => { + render( + } + overline="HTTP Tunneling" + title="Web Endpoints" + description="Tunnel HTTP traffic to your devices." + footnote="No VPN required." + > + + , + ); + + expect(screen.getByText("HTTP Tunneling")).toBeInTheDocument(); + expect( + screen.getByRole("heading", { level: 1, name: "Web Endpoints" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Tunnel HTTP traffic to your devices."), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Create your first endpoint" }), + ).toBeInTheDocument(); + expect(screen.getByText("No VPN required.")).toBeInTheDocument(); + }); + + it("names the region via its heading (aria-labelledby -> h1)", () => { + render( + } + overline="HTTP Tunneling" + title="Web Endpoints" + description="desc" + />, + ); + + // The
is an accessible region named by the

. + 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 */} -
+ ); } diff --git a/ui-react/apps/console/src/pages/firewall-rules/index.tsx b/ui-react/apps/console/src/pages/firewall-rules/index.tsx index 88d20d50b72..d6ab06fbe2b 100644 --- a/ui-react/apps/console/src/pages/firewall-rules/index.tsx +++ b/ui-react/apps/console/src/pages/firewall-rules/index.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useFirewallRules } from "@/hooks/useFirewallRules"; import { useDeleteFirewallRule } from "@/hooks/useFirewallRuleMutations"; import PageHeader from "@/components/common/PageHeader"; +import EmptyState from "@/components/common/EmptyState"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import DataTable, { type Column } from "@/components/common/DataTable"; import SearchField from "@/components/common/fields/SearchField"; @@ -193,109 +194,56 @@ export default function FirewallRules() { /* Full-page onboarding empty state (no rules at all) */ if (!isLoading && rules.length === 0) { return ( -
-
-
-
-
-
-
- -
-
-
-
- -
- - - Network Security - -

- Firewall Rules -

-

- Control who can access your devices and from where. Define - rules based on source IP, username, and device filter to - enforce your security policies. -

-
- -
- {[ - { - icon: , - title: "Allow & Deny", - description: - "Create rules to allow or block SSH connections based on your criteria.", - }, - { - icon: , - title: "User Filtering", - description: - "Restrict access per username, hostname, or source IP address.", - }, - { - icon: , - title: "Priority Order", - description: - "Organize rules by priority to control evaluation order.", - }, - ].map((h, idx) => ( -
-
- {h.icon} -
-

- {h.title} -

-

- {h.description} -

-
- ))} -
- -
- - - -

- Rules are evaluated by priority before connections reach - devices. -

-
-
-
-
+ <> + } + overline="Network Security" + title="Firewall Rules" + description="Control who can access your devices and from where. Define rules based on source IP, username, and device filter to enforce your security policies." + features={[ + { + icon: , + title: "Allow & Deny", + description: + "Create rules to allow or block SSH connections based on your criteria.", + }, + { + icon: , + title: "User Filtering", + description: + "Restrict access per username, hostname, or source IP address.", + }, + { + icon: , + title: "Priority Order", + description: + "Organize rules by priority to control evaluation order.", + }, + ]} + footnote="Rules are evaluated by priority before connections reach devices." + > + + + + -
+ ); } diff --git a/ui-react/apps/console/src/pages/public-keys/index.tsx b/ui-react/apps/console/src/pages/public-keys/index.tsx index efda1a4f2b4..51764ef1a16 100644 --- a/ui-react/apps/console/src/pages/public-keys/index.tsx +++ b/ui-react/apps/console/src/pages/public-keys/index.tsx @@ -3,6 +3,7 @@ import { usePublicKeys } from "@/hooks/usePublicKeys"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useDeletePublicKey } from "@/hooks/usePublicKeyMutations"; import PageHeader from "@/components/common/PageHeader"; +import EmptyState from "@/components/common/EmptyState"; import ConfirmDialog from "@/components/common/ConfirmDialog"; import CopyButton from "@/components/common/CopyButton"; import DataTable, { type Column } from "@/components/common/DataTable"; @@ -216,99 +217,51 @@ export default function PublicKeys() { /* Full-page onboarding empty state (no keys at all) */ if (!isLoading && publicKeys.length === 0 && !debouncedSearch) { return ( -
-
-
-
-
-
-
-
-
-
-
- -
- - SSH Authentication - -

- Public Keys -

-

- Set up SSH public keys to enable secure, passwordless - authentication to your devices. Manage access by user, - hostname, or device tags. -

-
-
- {[ - { - icon: , - title: "Passwordless Access", - description: - "Authenticate via SSH keys instead of passwords for stronger security.", - }, - { - icon: , - title: "User Control", - description: - "Restrict which usernames can connect with each public key.", - }, - { - icon: , - title: "Device Filtering", - description: - "Scope keys to specific devices using hostname patterns or tags.", - }, - ].map((h, idx) => ( -
-
- {h.icon} -
-

- {h.title} -

-

- {h.description} -

-
- ))} -
-
- - - -

- Supports RSA, DSA, ECDSA, and ED25519 key types. -

-
-
-
-
+ <> + } + overline="SSH Authentication" + title="Public Keys" + description="Set up SSH public keys to enable secure, passwordless authentication to your devices. Manage access by user, hostname, or device tags." + features={[ + { + icon: , + title: "Passwordless Access", + description: + "Authenticate via SSH keys instead of passwords for stronger security.", + }, + { + icon: , + title: "User Control", + description: + "Restrict which usernames can connect with each public key.", + }, + { + icon: , + title: "Device Filtering", + description: + "Scope keys to specific devices using hostname patterns or tags.", + }, + ]} + footnote="Supports RSA, DSA, ECDSA, and ED25519 key types." + > + + + + -
+ ); } diff --git a/ui-react/apps/console/src/pages/secure-vault/index.tsx b/ui-react/apps/console/src/pages/secure-vault/index.tsx index 24fac189e3b..4b1716ffa9b 100644 --- a/ui-react/apps/console/src/pages/secure-vault/index.tsx +++ b/ui-react/apps/console/src/pages/secure-vault/index.tsx @@ -10,6 +10,9 @@ import { } from "@heroicons/react/24/outline"; import { useVaultStore } from "@/stores/vaultStore"; import PageHeader from "@/components/common/PageHeader"; +import EmptyState, { + type EmptyStateFeature, +} from "@/components/common/EmptyState"; import CopyButton from "@/components/common/CopyButton"; import VaultSetupDialog from "@/components/vault/VaultSetupDialog"; import VaultUnlockDialog from "@/components/vault/VaultUnlockDialog"; @@ -21,6 +24,27 @@ import KeyDeleteDialog from "./KeyDeleteDialog"; import { formatDate } from "@/utils/date"; import type { VaultKeyEntry } from "@/types/vault"; +const VAULT_FEATURES: EmptyStateFeature[] = [ + { + icon: , + title: "AES-256 Encryption", + description: + "Keys are encrypted with AES-256-GCM, derived from your master password.", + }, + { + icon: , + title: "Zero Knowledge", + description: + "Your master password is never stored — only you can unlock the vault.", + }, + { + icon: , + title: "Quick Connect", + description: + "Select stored keys when connecting to devices — no more copy-pasting.", + }, +]; + export default function SecureVault() { const status = useVaultStore((s) => s.status); const keys = useVaultStore((s) => s.keys); @@ -60,86 +84,21 @@ export default function SecureVault() { if (status === "uninitialized") { return ( <> -
-
-
-
-
-
-
-
-
-
- -
- - Encrypted Key Storage - -

- Secure Vault -

-

- Store and encrypt your SSH private keys with a master - password. Your keys never leave your browser and are protected - at rest. -

-
-
- {[ - { - icon: , - title: "AES-256 Encryption", - description: - "Keys are encrypted with AES-256-GCM, derived from your master password.", - }, - { - icon: , - title: "Zero Knowledge", - description: - "Your master password is never stored — only you can unlock the vault.", - }, - { - icon: , - title: "Quick Connect", - description: - "Select stored keys when connecting to devices — no more copy-pasting.", - }, - ].map((h, idx) => ( -
-
- {h.icon} -
-

- {h.title} -

-

- {h.description} -

-
- ))} -
-
- -
-
-
-
+ } + overline="Encrypted Key Storage" + title="Secure Vault" + description="Store and encrypt your SSH private keys with a master password. Your keys never leave your browser and are protected at rest." + features={VAULT_FEATURES} + > + + setSetupOpen(false)} @@ -151,85 +110,22 @@ export default function SecureVault() { if (status === "locked") { return ( <> -
-
-
-
-
-
-
-
-
-
- -
- - Vault Locked - -

- Your vault is locked -

-

- Enter your master password to access your SSH keys and connect - to devices. -

-
-
- {[ - { - icon: , - title: "AES-256 Encryption", - description: - "Keys are encrypted with AES-256-GCM, derived from your master password.", - }, - { - icon: , - title: "Zero Knowledge", - description: - "Your master password is never stored — only you can unlock the vault.", - }, - { - icon: , - title: "Quick Connect", - description: - "Select stored keys when connecting to devices — no more copy-pasting.", - }, - ].map((h, idx) => ( -
-
- {h.icon} -
-

- {h.title} -

-

- {h.description} -

-
- ))} -
-
- -
-
-
-
+ } + overline="Vault Locked" + title="Your vault is locked" + description="Enter your master password to access your SSH keys and connect to devices." + features={VAULT_FEATURES} + > + + setUnlockOpen(false)}