- {/* Desktop Sidebar */}
- {showSidebar && (
-
- )}
-
- {/* Main content area */}
-
- {/* Mobile header */}
- {showHeader && (
-
+
+ {/* Desktop Sidebar */}
+ {showSidebar && (
+
)}
- {/* Desktop header */}
- {showHeader &&
}
+ {/* Main content area */}
+
+ {/* Mobile header */}
+ {showHeader && (
+
+ )}
- {/* Main content */}
-
-
- {children}
-
-
+ {/* Desktop header */}
+ {showHeader &&
}
+
+ {/* Main content */}
+
+
+ {children}
+
+
+
-
+
);
}
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
new file mode 100644
index 000000000..02556b7dd
--- /dev/null
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -0,0 +1,368 @@
+"use client";
+
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator
+} from "@app/components/ui/command";
+import type { SidebarNavSection } from "@app/app/navigation";
+import { Badge } from "@app/components/ui/badge";
+import { ListUserOrgsResponse } from "@server/routers/org";
+import { Loader2 } from "lucide-react";
+import { useRouter } from "next/navigation";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState
+} from "react";
+import { useTranslations } from "next-intl";
+import { useCommandPaletteActions } from "./useCommandPaletteActions";
+import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
+import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations";
+import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
+
+type CommandPaletteProps = {
+ orgId?: string;
+ orgs?: ListUserOrgsResponse["orgs"];
+ navItems: SidebarNavSection[];
+};
+
+export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
+ const t = useTranslations();
+ const router = useRouter();
+ const { open, setOpen } = useCommandPalette();
+ const [search, setSearch] = useState("");
+
+ const navigationGroups = useCommandPaletteNavigation(navItems);
+ const organizations = useCommandPaletteOrganizations(orgs);
+ const actions = useCommandPaletteActions(orgId, orgs);
+ const { shouldSearch, sites, resources, users, machineClients, isLoading } =
+ useCommandPaletteSearch({
+ orgId,
+ query: search,
+ enabled: open
+ });
+
+ const handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (!nextOpen) {
+ setSearch("");
+ }
+ },
+ [setOpen]
+ );
+
+ const runCommand = useCallback(
+ (command: () => void) => {
+ setOpen(false);
+ setSearch("");
+ command();
+ },
+ [setOpen]
+ );
+
+ const hasEntityResults =
+ sites.length > 0 ||
+ resources.length > 0 ||
+ users.length > 0 ||
+ machineClients.length > 0;
+
+ return (
+
+
+
+ {t("commandPaletteNoResults")}
+
+ {navigationGroups.map((group) => (
+
+ {group.items.map((item) => (
+
+ runCommand(() => router.push(item.href))
+ }
+ >
+ {item.icon}
+ {item.title}
+
+ ))}
+
+ ))}
+
+ {organizations.length > 1 && (
+ <>
+
+
+ {organizations.map((org) => (
+
+ runCommand(() => router.push(org.href))
+ }
+ >
+ {org.name}
+
+ {org.orgId}
+
+ {org.isPrimaryOrg && (
+
+ {t("primary")}
+
+ )}
+
+ ))}
+
+ >
+ )}
+
+ {shouldSearch && orgId && (
+ <>
+
+ {isLoading && !hasEntityResults ? (
+
+
+ {t("commandPaletteSearching")}
+
+ ) : (
+ <>
+ {sites.length > 0 && (
+
+ {sites.map((site) => (
+
+ runCommand(() =>
+ router.push(site.href)
+ )
+ }
+ >
+
+ {site.name}
+
+
+ ))}
+
+ )}
+ {resources.length > 0 && (
+
+ {resources.map((resource) => (
+
+ runCommand(() =>
+ router.push(
+ resource.href
+ )
+ )
+ }
+ >
+
+ {resource.name}
+
+
+ ))}
+
+ )}
+ {users.length > 0 && (
+
+ {users.map((user) => (
+
+ runCommand(() =>
+ router.push(user.href)
+ )
+ }
+ >
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+ ))}
+
+ )}
+ {machineClients.length > 0 && (
+
+ {machineClients.map((client) => (
+
+ runCommand(() =>
+ router.push(client.href)
+ )
+ }
+ >
+
+ {client.name}
+
+
+ ))}
+
+ )}
+ >
+ )}
+ >
+ )}
+
+ {actions.length > 0 && (
+ <>
+
+
+ {actions.map((action) => (
+
+ runCommand(() => {
+ if (action.onSelect) {
+ action.onSelect();
+ } else if (action.href) {
+ router.push(action.href);
+ }
+ })
+ }
+ >
+ {action.icon}
+ {action.label}
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
+
+/*******************************/
+/* COMMAND PALETTE CONTEXT */
+/*******************************/
+export type CommandPaletteContextValue = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ toggle: () => void;
+};
+
+const CommandPaletteContext = createContext
(
+ null
+);
+
+export function useCommandPalette() {
+ const context = useContext(CommandPaletteContext);
+ if (!context) {
+ throw new Error(
+ "useCommandPalette must be used within CommandPaletteProvider"
+ );
+ }
+ return context;
+}
+
+//*******************************/
+/* COMMAND PALETTE PROVIDER */
+/*******************************/
+type CommandPaletteProviderProps = {
+ children: React.ReactNode;
+ orgId?: string;
+ orgs?: ListUserOrgsResponse["orgs"];
+ navItems: SidebarNavSection[];
+};
+
+function isEditableTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) return false;
+ if (target.isContentEditable) return true;
+ const tagName = target.tagName;
+ return (
+ tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT"
+ );
+}
+
+export function CommandPaletteProvider({
+ children,
+ orgId,
+ orgs,
+ navItems
+}: CommandPaletteProviderProps) {
+ const [open, setOpen] = useState(false);
+
+ const toggle = useCallback(() => {
+ setOpen((current) => !current);
+ }, []);
+
+ const contextValue = useMemo(
+ () => ({
+ open,
+ setOpen,
+ toggle
+ }),
+ [open, toggle]
+ );
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ if (
+ event.key.toLowerCase() !== "k" ||
+ !(event.metaKey || event.ctrlKey)
+ ) {
+ return;
+ }
+
+ if (!open && isEditableTarget(event.target)) {
+ return;
+ }
+
+ event.preventDefault();
+ toggle();
+ }
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [open, toggle]);
+
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/command-palette/CommandPaletteTrigger.tsx b/src/components/command-palette/CommandPaletteTrigger.tsx
new file mode 100644
index 000000000..785013ae2
--- /dev/null
+++ b/src/components/command-palette/CommandPaletteTrigger.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import { CommandShortcut } from "@app/components/ui/command";
+import { cn } from "@app/lib/cn";
+import { Search } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useCommandPalette } from "./CommandPalette";
+
+type CommandPaletteTriggerProps = {
+ variant?: "header" | "mobile";
+ className?: string;
+};
+
+function useIsMac() {
+ const [isMac, setIsMac] = useState(false);
+
+ useEffect(() => {
+ setIsMac(/Mac|iPhone|iPod|iPad/.test(navigator.platform));
+ }, []);
+
+ return isMac;
+}
+
+export function CommandPaletteTrigger({
+ variant = "header",
+ className
+}: CommandPaletteTriggerProps) {
+ const t = useTranslations();
+ const { setOpen } = useCommandPalette();
+ const isMac = useIsMac();
+
+ if (variant === "mobile") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx
new file mode 100644
index 000000000..998fb97b8
--- /dev/null
+++ b/src/components/command-palette/useCommandPaletteActions.tsx
@@ -0,0 +1,161 @@
+"use client";
+
+import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useUserContext } from "@app/hooks/useUserContext";
+import { build } from "@server/build";
+import {
+ BellRing,
+ Building2,
+ Globe,
+ KeyRound,
+ MonitorUp,
+ Plus,
+ SunMoon,
+ UserPlus
+} from "lucide-react";
+import { useTheme } from "next-themes";
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+import { ListUserOrgsResponse } from "@server/routers/org";
+
+export type CommandPaletteAction = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ href?: string;
+ onSelect?: () => void;
+};
+
+export function useCommandPaletteActions(
+ orgId?: string,
+ orgs?: ListUserOrgsResponse["orgs"]
+): CommandPaletteAction[] {
+ const t = useTranslations();
+ const pathname = usePathname();
+ const { env } = useEnvContext();
+ const { user } = useUserContext();
+ const { setTheme, theme } = useTheme();
+ const isAdminPage = pathname?.startsWith("/admin");
+
+ return useMemo(() => {
+ const actions: CommandPaletteAction[] = [];
+
+ function cycleTheme() {
+ const currentTheme = theme || "system";
+ if (currentTheme === "light") {
+ setTheme("dark");
+ } else if (currentTheme === "dark") {
+ setTheme("system");
+ } else {
+ setTheme("light");
+ }
+ }
+
+ if (isAdminPage) {
+ actions.push({
+ id: "create-admin-api-key",
+ label: t("commandPaletteCreateApiKey"),
+ icon: ,
+ href: "/admin/api-keys/create"
+ });
+
+ if (
+ build === "oss" ||
+ env?.app.identityProviderMode === "global" ||
+ env?.app.identityProviderMode === undefined
+ ) {
+ actions.push({
+ id: "create-admin-idp",
+ label: t("commandPaletteCreateIdentityProvider"),
+ icon: ,
+ href: "/admin/idp/create"
+ });
+ }
+ } else if (orgId) {
+ actions.push({
+ id: "create-site",
+ label: t("commandPaletteCreateSite"),
+ icon: ,
+ href: `/${orgId}/settings/sites/create`
+ });
+ actions.push({
+ id: "create-proxy-resource",
+ label: t("commandPaletteCreateProxyResource"),
+ icon: ,
+ href: `/${orgId}/settings/resources/proxy/create`
+ });
+ actions.push({
+ id: "create-user",
+ label: t("commandPaletteCreateUser"),
+ icon: ,
+ href: `/${orgId}/settings/access/users/create`
+ });
+ actions.push({
+ id: "create-api-key",
+ label: t("commandPaletteCreateApiKey"),
+ icon: ,
+ href: `/${orgId}/settings/api-keys/create`
+ });
+ actions.push({
+ id: "create-machine-client",
+ label: t("commandPaletteCreateMachineClient"),
+ icon: ,
+ href: `/${orgId}/settings/clients/machine/create`
+ });
+
+ if (!env?.flags.disableEnterpriseFeatures) {
+ actions.push({
+ id: "create-alert-rule",
+ label: t("commandPaletteCreateAlertRule"),
+ icon: ,
+ href: `/${orgId}/settings/alerting/create`
+ });
+ }
+
+ if (
+ (build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
+ build === "saas" ||
+ env?.app.identityProviderMode === "org" ||
+ (env?.app.identityProviderMode === undefined && build !== "oss")
+ ) {
+ actions.push({
+ id: "create-idp",
+ label: t("commandPaletteCreateIdentityProvider"),
+ icon: ,
+ href: `/${orgId}/settings/idp/create`
+ });
+ }
+ }
+
+ const canChooseOrganization = !isAdminPage && (orgs?.length ?? 0) > 1;
+
+ if (canChooseOrganization) {
+ actions.push({
+ id: "choose-org",
+ label: t("commandPaletteChooseOrganization"),
+ icon: ,
+ href: "/?orgs=1"
+ });
+ }
+
+ actions.push({
+ id: "toggle-theme",
+ label: t("commandPaletteToggleTheme"),
+ icon: ,
+ onSelect: cycleTheme
+ });
+
+ if (user.serverAdmin && !isAdminPage) {
+ actions.push({
+ id: "go-admin",
+ label: t("serverAdmin"),
+ icon: ,
+ href: "/admin/users"
+ });
+ }
+
+ return actions;
+ }, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
+}
\ No newline at end of file
diff --git a/src/components/command-palette/useCommandPaletteNavigation.ts b/src/components/command-palette/useCommandPaletteNavigation.ts
new file mode 100644
index 000000000..1968d1a89
--- /dev/null
+++ b/src/components/command-palette/useCommandPaletteNavigation.ts
@@ -0,0 +1,57 @@
+"use client";
+
+import type { SidebarNavSection } from "@app/components/SidebarNav";
+import { flattenNavSections } from "@app/lib/flattenNavItems";
+import {
+ hydrateNavHref,
+ navHrefParamsFromRoute
+} from "@app/lib/hydrateNavHref";
+import { useParams } from "next/navigation";
+import { useMemo } from "react";
+import { useTranslations } from "next-intl";
+
+export type NavigationCommand = {
+ id: string;
+ title: string;
+ href: string;
+ icon?: React.ReactNode;
+ sectionHeading: string;
+};
+
+export type NavigationCommandGroup = {
+ heading: string;
+ items: NavigationCommand[];
+};
+
+export function useCommandPaletteNavigation(
+ navItems: SidebarNavSection[]
+): NavigationCommandGroup[] {
+ const params = useParams();
+ const t = useTranslations();
+
+ return useMemo(() => {
+ const hrefParams = navHrefParamsFromRoute(params);
+ const flat = flattenNavSections(navItems);
+ const groups = new Map();
+
+ for (const item of flat) {
+ const href = hydrateNavHref(item.href, hrefParams);
+ if (!href) continue;
+
+ const groupItems = groups.get(item.sectionHeading) ?? [];
+ groupItems.push({
+ id: `nav-${item.sectionHeading}-${item.title}-${href}`,
+ title: t(item.title),
+ href,
+ icon: item.icon,
+ sectionHeading: item.sectionHeading
+ });
+ groups.set(item.sectionHeading, groupItems);
+ }
+
+ return Array.from(groups.entries()).map(([heading, items]) => ({
+ heading: t(heading),
+ items
+ }));
+ }, [navItems, params, t]);
+}
diff --git a/src/components/command-palette/useCommandPaletteOrganizations.ts b/src/components/command-palette/useCommandPaletteOrganizations.ts
new file mode 100644
index 000000000..1e5343ccd
--- /dev/null
+++ b/src/components/command-palette/useCommandPaletteOrganizations.ts
@@ -0,0 +1,45 @@
+"use client";
+
+import { ListUserOrgsResponse } from "@server/routers/org";
+import { usePathname } from "next/navigation";
+import { useMemo } from "react";
+
+export type OrganizationCommand = {
+ id: string;
+ orgId: string;
+ name: string;
+ isPrimaryOrg?: boolean;
+ href: string;
+};
+
+export function useCommandPaletteOrganizations(
+ orgs: ListUserOrgsResponse["orgs"] | undefined
+): OrganizationCommand[] {
+ const pathname = usePathname();
+
+ return useMemo(() => {
+ if (!orgs?.length) return [];
+
+ const sortedOrgs = [...orgs].sort((a, b) => {
+ const aPrimary = Boolean(a.isPrimaryOrg);
+ const bPrimary = Boolean(b.isPrimaryOrg);
+ if (aPrimary && !bPrimary) return -1;
+ if (!aPrimary && bPrimary) return 1;
+ return 0;
+ });
+
+ return sortedOrgs.map((org) => {
+ const newPath = pathname.includes("/settings/")
+ ? pathname.replace(/^\/[^/]+/, `/${org.orgId}`)
+ : `/${org.orgId}`;
+
+ return {
+ id: `org-${org.orgId}`,
+ orgId: org.orgId,
+ name: org.name,
+ isPrimaryOrg: org.isPrimaryOrg,
+ href: newPath
+ };
+ });
+ }, [orgs, pathname]);
+}
diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts
new file mode 100644
index 000000000..984892354
--- /dev/null
+++ b/src/components/command-palette/useCommandPaletteSearch.ts
@@ -0,0 +1,142 @@
+"use client";
+
+import { orgQueries } from "@app/lib/queries";
+import { useQueries } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useDebounce } from "use-debounce";
+
+const SEARCH_PER_PAGE = 5;
+const MIN_QUERY_LENGTH = 2;
+
+export type SiteSearchResult = {
+ id: string;
+ name: string;
+ href: string;
+};
+
+export type ResourceSearchResult = {
+ id: string;
+ name: string;
+ href: string;
+};
+
+export type UserSearchResult = {
+ id: string;
+ name: string;
+ email: string;
+ href: string;
+};
+
+export type ClientSearchResult = {
+ id: string;
+ name: string;
+ href: string;
+};
+
+export function useCommandPaletteSearch({
+ orgId,
+ query,
+ enabled
+}: {
+ orgId?: string;
+ query: string;
+ enabled: boolean;
+}) {
+ const [debouncedQuery] = useDebounce(query, 150);
+ const trimmedQuery = debouncedQuery.trim();
+ const shouldSearch =
+ enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
+
+ const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({
+ queries: [
+ {
+ ...orgQueries.sites({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
+ },
+ {
+ ...orgQueries.resources({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
+ },
+ {
+ ...orgQueries.users({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
+ },
+ {
+ ...orgQueries.machineClients({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
+ }
+ ]
+ });
+
+ const sites = useMemo((): SiteSearchResult[] => {
+ if (!orgId || !sitesQuery.data) return [];
+ return sitesQuery.data.map((site) => ({
+ id: `site-${site.siteId}`,
+ name: site.name,
+ href: `/${orgId}/settings/sites/${site.niceId}`
+ }));
+ }, [orgId, sitesQuery.data]);
+
+ const resources = useMemo((): ResourceSearchResult[] => {
+ if (!orgId || !resourcesQuery.data) return [];
+ return resourcesQuery.data.map((resource) => ({
+ id: `resource-${resource.resourceId}`,
+ name: resource.name,
+ href: `/${orgId}/settings/resources/proxy/${resource.niceId}`
+ }));
+ }, [orgId, resourcesQuery.data]);
+
+ const users = useMemo((): UserSearchResult[] => {
+ if (!orgId || !usersQuery.data) return [];
+ return usersQuery.data.map((user) => ({
+ id: `user-${user.id}`,
+ name: user.name ?? user.email ?? user.username ?? "",
+ email: user.email ?? user.username ?? "",
+ href: `/${orgId}/settings/access/users/${user.id}`
+ }));
+ }, [orgId, usersQuery.data]);
+
+ const machineClients = useMemo((): ClientSearchResult[] => {
+ if (!orgId || !clientsQuery.data) return [];
+ return clientsQuery.data
+ .filter((client) => !client.userId)
+ .map((client) => ({
+ id: `client-${client.clientId}`,
+ name: client.name,
+ href: `/${orgId}/settings/clients/machine/${client.niceId}`
+ }));
+ }, [orgId, clientsQuery.data]);
+
+ const isLoading =
+ shouldSearch &&
+ (sitesQuery.isFetching ||
+ resourcesQuery.isFetching ||
+ usersQuery.isFetching ||
+ clientsQuery.isFetching);
+
+ return {
+ debouncedQuery: trimmedQuery,
+ shouldSearch,
+ sites,
+ resources,
+ users,
+ machineClients,
+ isLoading
+ };
+}
diff --git a/src/lib/flattenNavItems.ts b/src/lib/flattenNavItems.ts
new file mode 100644
index 000000000..b64268322
--- /dev/null
+++ b/src/lib/flattenNavItems.ts
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+import type {
+ SidebarNavItem,
+ SidebarNavSection
+} from "@app/components/SidebarNav";
+
+export type FlatNavItem = {
+ title: string;
+ href: string;
+ icon?: ReactNode;
+ sectionHeading: string;
+};
+
+function flattenItems(
+ items: SidebarNavItem[],
+ sectionHeading: string,
+ result: FlatNavItem[]
+) {
+ for (const item of items) {
+ if (item.href) {
+ result.push({
+ title: item.title,
+ href: item.href,
+ icon: item.icon,
+ sectionHeading
+ });
+ }
+ if (item.items?.length) {
+ flattenItems(item.items, sectionHeading, result);
+ }
+ }
+}
+
+export function flattenNavSections(
+ sections: SidebarNavSection[]
+): FlatNavItem[] {
+ const result: FlatNavItem[] = [];
+ for (const section of sections) {
+ flattenItems(section.items, section.heading, result);
+ }
+ return result;
+}
diff --git a/src/lib/hydrateNavHref.ts b/src/lib/hydrateNavHref.ts
new file mode 100644
index 000000000..45fa0f5ee
--- /dev/null
+++ b/src/lib/hydrateNavHref.ts
@@ -0,0 +1,35 @@
+export type NavHrefParams = {
+ orgId?: string;
+ niceId?: string;
+ resourceId?: string;
+ userId?: string;
+ apiKeyId?: string;
+ clientId?: string;
+};
+
+export function hydrateNavHref(
+ val: string | undefined,
+ params: NavHrefParams
+): string | undefined {
+ if (!val) return undefined;
+ return val
+ .replace("{orgId}", params.orgId ?? "")
+ .replace("{niceId}", params.niceId ?? "")
+ .replace("{resourceId}", params.resourceId ?? "")
+ .replace("{userId}", params.userId ?? "")
+ .replace("{apiKeyId}", params.apiKeyId ?? "")
+ .replace("{clientId}", params.clientId ?? "");
+}
+
+export function navHrefParamsFromRoute(
+ params: Record
+): NavHrefParams {
+ return {
+ orgId: params.orgId as string | undefined,
+ niceId: params.niceId as string | undefined,
+ resourceId: params.resourceId as string | undefined,
+ userId: params.userId as string | undefined,
+ apiKeyId: params.apiKeyId as string | undefined,
+ clientId: params.clientId as string | undefined
+ };
+}
From b136bd2246349da061067c3731ba8ff593f8c5c2 Mon Sep 17 00:00:00 2001
From: kshitijshresth
Date: Fri, 12 Jun 2026 11:21:21 +0300
Subject: [PATCH 013/309] Escape regex metacharacters in PATH rule wildcard
matching
isValidUrlGlobPattern accepts characters like ( ) [ ] { } | . + ^ $ in PATH rule values, but isPathAllowed converted wildcard segments to regex without escaping them. A rule value such as /(api* produced an invalid regex and threw on every request to the resource, surfacing as a 500 from verifySession. Literal characters like . and + also changed matching semantics. isPathAllowed is extracted to server/lib/pathMatch.ts as a pure module, metacharacters are escaped before wildcard substitution, compiled segment regexes are cached, and the test suite now imports the real implementation instead of a stale copy, with added coverage for special characters.
---
server/lib/pathMatch.ts | 74 ++++++++
server/routers/badger/verifySession.test.ts | 187 ++++++++++++--------
server/routers/badger/verifySession.ts | 139 +--------------
3 files changed, 193 insertions(+), 207 deletions(-)
create mode 100644 server/lib/pathMatch.ts
diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts
new file mode 100644
index 000000000..a007f9ec3
--- /dev/null
+++ b/server/lib/pathMatch.ts
@@ -0,0 +1,74 @@
+const MAX_RECURSION_DEPTH = 100;
+
+const segmentRegexCache = new Map();
+
+function getSegmentRegex(patternPart: string): RegExp {
+ let regex = segmentRegexCache.get(patternPart);
+ if (!regex) {
+ const regexPattern = patternPart
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ .replace(/\*/g, ".*")
+ .replace(/\?/g, ".");
+ regex = new RegExp(`^${regexPattern}$`);
+ segmentRegexCache.set(patternPart, regex);
+ }
+ return regex;
+}
+
+export function isPathAllowed(pattern: string, path: string): boolean {
+ const normalize = (p: string) => p.split("/").filter(Boolean);
+ const patternParts = normalize(pattern);
+ const pathParts = normalize(path);
+
+ function matchSegments(
+ patternIndex: number,
+ pathIndex: number,
+ depth: number = 0
+ ): boolean {
+ if (depth > MAX_RECURSION_DEPTH) {
+ return false;
+ }
+
+ const currentPatternPart = patternParts[patternIndex];
+ const currentPathPart = pathParts[pathIndex];
+
+ if (patternIndex >= patternParts.length) {
+ return pathIndex >= pathParts.length;
+ }
+
+ if (pathIndex >= pathParts.length) {
+ return patternParts.slice(patternIndex).every((p) => p === "*");
+ }
+
+ if (currentPatternPart === "*") {
+ if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
+ return true;
+ }
+ if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
+ return true;
+ }
+ return false;
+ }
+
+ if (currentPatternPart.includes("*")) {
+ const regex = getSegmentRegex(currentPatternPart);
+
+ if (regex.test(currentPathPart)) {
+ return matchSegments(
+ patternIndex + 1,
+ pathIndex + 1,
+ depth + 1
+ );
+ }
+ return false;
+ }
+
+ if (currentPatternPart !== currentPathPart) {
+ return false;
+ }
+
+ return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
+ }
+
+ return matchSegments(0, 0, 0);
+}
diff --git a/server/routers/badger/verifySession.test.ts b/server/routers/badger/verifySession.test.ts
index 8333a4578..681717f9c 100644
--- a/server/routers/badger/verifySession.test.ts
+++ b/server/routers/badger/verifySession.test.ts
@@ -1,5 +1,6 @@
import { assertEquals } from "@test/assert";
import { REGIONS } from "@server/db/regions";
+import { isPathAllowed } from "@server/lib/pathMatch";
function isIpInRegion(
ipCountryCode: string | undefined,
@@ -33,76 +34,6 @@ function isIpInRegion(
return false;
}
-function isPathAllowed(pattern: string, path: string): boolean {
- // Normalize and split paths into segments
- const normalize = (p: string) => p.split("/").filter(Boolean);
- const patternParts = normalize(pattern);
- const pathParts = normalize(path);
-
- // Recursive function to try different wildcard matches
- function matchSegments(patternIndex: number, pathIndex: number): boolean {
- const indent = " ".repeat(pathIndex); // Indent based on recursion depth
- const currentPatternPart = patternParts[patternIndex];
- const currentPathPart = pathParts[pathIndex];
-
- // If we've consumed all pattern parts, we should have consumed all path parts
- if (patternIndex >= patternParts.length) {
- const result = pathIndex >= pathParts.length;
- return result;
- }
-
- // If we've consumed all path parts but still have pattern parts
- if (pathIndex >= pathParts.length) {
- // The only way this can match is if all remaining pattern parts are wildcards
- const remainingPattern = patternParts.slice(patternIndex);
- const result = remainingPattern.every((p) => p === "*");
- return result;
- }
-
- // For full segment wildcards, try consuming different numbers of path segments
- if (currentPatternPart === "*") {
- // Try consuming 0 segments (skip the wildcard)
- if (matchSegments(patternIndex + 1, pathIndex)) {
- return true;
- }
-
- // Try consuming current segment and recursively try rest
- if (matchSegments(patternIndex, pathIndex + 1)) {
- return true;
- }
-
- return false;
- }
-
- // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
- if (currentPatternPart.includes("*")) {
- // Convert the pattern segment to a regex pattern
- const regexPattern = currentPatternPart
- .replace(/\*/g, ".*") // Replace * with .* for regex wildcard
- .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
-
- const regex = new RegExp(`^${regexPattern}$`);
-
- if (regex.test(currentPathPart)) {
- return matchSegments(patternIndex + 1, pathIndex + 1);
- }
-
- return false;
- }
-
- // For regular segments, they must match exactly
- if (currentPatternPart !== currentPathPart) {
- return false;
- }
-
- // Move to next segments in both pattern and path
- return matchSegments(patternIndex + 1, pathIndex + 1);
- }
-
- const result = matchSegments(0, 0);
- return result;
-}
-
function runTests() {
console.log("Running path matching tests...");
@@ -308,6 +239,121 @@ function runTests() {
console.log("All path matching tests passed!");
}
+function runSpecialCharacterTests() {
+ console.log("\nRunning special character tests...");
+
+ let threw = false;
+ try {
+ isPathAllowed("(api*", "anything");
+ isPathAllowed("a(b*", "a(bc");
+ isPathAllowed("c[d*", "c[de");
+ isPathAllowed("x{2}*", "x{2}y");
+ isPathAllowed("a|b*", "a|bc");
+ isPathAllowed("back\\slash*", "back\\slashed");
+ } catch (e) {
+ threw = true;
+ console.error(
+ "Patterns accepted by isValidUrlGlobPattern crashed the matcher:",
+ e instanceof Error ? e.message : e
+ );
+ }
+ assertEquals(
+ threw,
+ false,
+ "Patterns with regex metacharacters must not throw"
+ );
+
+ assertEquals(
+ isPathAllowed("(api*", "(api-v1"),
+ true,
+ "Parenthesis should be treated as a literal character"
+ );
+ assertEquals(
+ isPathAllowed("(api*", "xapi-v1"),
+ false,
+ "Parenthesis should not match other characters"
+ );
+ assertEquals(
+ isPathAllowed("a(b)*", "a(b)c"),
+ true,
+ "Parentheses pair should be treated as literal characters"
+ );
+
+ assertEquals(
+ isPathAllowed("*.png", "image.png"),
+ true,
+ "Dot should match a literal dot"
+ );
+ assertEquals(
+ isPathAllowed("*.png", "imageXpng"),
+ false,
+ "Dot should not act as a regex wildcard"
+ );
+ assertEquals(
+ isPathAllowed("v1.0*", "v1.0.1"),
+ true,
+ "Version-like literal should match itself"
+ );
+ assertEquals(
+ isPathAllowed("v1.0*", "v1x0-beta"),
+ false,
+ "Version-like literal should not match arbitrary characters"
+ );
+
+ assertEquals(
+ isPathAllowed("a+b*", "a+bc"),
+ true,
+ "Plus should be treated as a literal character"
+ );
+ assertEquals(
+ isPathAllowed("a+b*", "aaabc"),
+ false,
+ "Plus should not act as a regex quantifier"
+ );
+
+ assertEquals(
+ isPathAllowed("$ref*", "$refs"),
+ true,
+ "Dollar sign should be treated as a literal character"
+ );
+ assertEquals(
+ isPathAllowed("price$*", "price$100"),
+ true,
+ "Dollar sign mid-pattern should be treated as a literal character"
+ );
+
+ assertEquals(
+ isPathAllowed("^start*", "^started"),
+ true,
+ "Caret should be treated as a literal character"
+ );
+
+ assertEquals(
+ isPathAllowed("a|b*", "a|bc"),
+ true,
+ "Pipe should be treated as a literal character"
+ );
+ assertEquals(
+ isPathAllowed("a|b*", "a"),
+ false,
+ "Pipe should not act as regex alternation"
+ );
+
+ assertEquals(
+ isPathAllowed("file?*", "fileX"),
+ true,
+ "Question mark should still act as a single-character wildcard"
+ );
+
+ assertEquals(
+ isPathAllowed("api/*", "api/" + "x/".repeat(50)),
+ true,
+ "Deeply nested paths should still match"
+ );
+
+ console.log("All special character tests passed!");
+}
+
function runRegionTests() {
console.log("\nRunning isIpInRegion tests...");
@@ -367,6 +413,7 @@ function runRegionTests() {
// Run all tests
try {
runTests();
+ runSpecialCharacterTests();
runRegionTests();
console.log("\n✅ All tests passed!");
} catch (error) {
diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts
index 677fa281d..d3076ec4d 100644
--- a/server/routers/badger/verifySession.ts
+++ b/server/routers/badger/verifySession.ts
@@ -25,6 +25,7 @@ import {
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
+import { isPathAllowed } from "@server/lib/pathMatch";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -1090,143 +1091,7 @@ async function checkRules(
return;
}
-export function isPathAllowed(pattern: string, path: string): boolean {
- logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
-
- // Normalize and split paths into segments
- const normalize = (p: string) => p.split("/").filter(Boolean);
- const patternParts = normalize(pattern);
- const pathParts = normalize(path);
-
- logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
- logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
-
- // Maximum recursion depth to prevent stack overflow and memory issues
- const MAX_RECURSION_DEPTH = 100;
-
- // Recursive function to try different wildcard matches
- function matchSegments(
- patternIndex: number,
- pathIndex: number,
- depth: number = 0
- ): boolean {
- // Check recursion depth limit
- if (depth > MAX_RECURSION_DEPTH) {
- logger.warn(
- `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"`
- );
- return false;
- }
-
- const indent = " ".repeat(depth); // Indent based on recursion depth
- const currentPatternPart = patternParts[patternIndex];
- const currentPathPart = pathParts[pathIndex];
-
- logger.debug(
- `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]`
- );
-
- // If we've consumed all pattern parts, we should have consumed all path parts
- if (patternIndex >= patternParts.length) {
- const result = pathIndex >= pathParts.length;
- logger.debug(
- `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
- );
- return result;
- }
-
- // If we've consumed all path parts but still have pattern parts
- if (pathIndex >= pathParts.length) {
- // The only way this can match is if all remaining pattern parts are wildcards
- const remainingPattern = patternParts.slice(patternIndex);
- const result = remainingPattern.every((p) => p === "*");
- logger.debug(
- `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
- );
- return result;
- }
-
- // For full segment wildcards, try consuming different numbers of path segments
- if (currentPatternPart === "*") {
- logger.debug(
- `${indent}Found wildcard at pattern index ${patternIndex}`
- );
-
- // Try consuming 0 segments (skip the wildcard)
- logger.debug(
- `${indent}Trying to skip wildcard (consume 0 segments)`
- );
- if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
- logger.debug(
- `${indent}Successfully matched by skipping wildcard`
- );
- return true;
- }
-
- // Try consuming current segment and recursively try rest
- logger.debug(
- `${indent}Trying to consume segment "${currentPathPart}" for wildcard`
- );
- if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
- logger.debug(
- `${indent}Successfully matched by consuming segment for wildcard`
- );
- return true;
- }
-
- logger.debug(`${indent}Failed to match wildcard`);
- return false;
- }
-
- // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
- if (currentPatternPart.includes("*")) {
- logger.debug(
- `${indent}Found in-segment wildcard in "${currentPatternPart}"`
- );
-
- // Convert the pattern segment to a regex pattern
- const regexPattern = currentPatternPart
- .replace(/\*/g, ".*") // Replace * with .* for regex wildcard
- .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
-
- const regex = new RegExp(`^${regexPattern}$`);
-
- if (regex.test(currentPathPart)) {
- logger.debug(
- `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
- );
- return matchSegments(
- patternIndex + 1,
- pathIndex + 1,
- depth + 1
- );
- }
-
- logger.debug(
- `${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
- );
- return false;
- }
-
- // For regular segments, they must match exactly
- if (currentPatternPart !== currentPathPart) {
- logger.debug(
- `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
- );
- return false;
- }
-
- logger.debug(
- `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
- );
- // Move to next segments in both pattern and path
- return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
- }
-
- const result = matchSegments(0, 0, 0);
- logger.debug(`Final result: ${result}`);
- return result;
-}
+export { isPathAllowed };
async function isIpInGeoIP(
ipCountryCode: string | undefined,
From a68b57067c5b93662e18c1da0f9f48d3d41ebff9 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 12 Jun 2026 23:51:05 +0200
Subject: [PATCH 014/309] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20Use=20command=20b?=
=?UTF-8?q?ar=20nav=20items?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/[orgId]/settings/layout.tsx | 5 +-
src/app/navigation.tsx | 250 ++++++++++++++++++
src/components/Layout.tsx | 8 +-
src/components/SitesTable.tsx | 7 -
.../command-palette/CommandPalette.tsx | 85 +++---
5 files changed, 315 insertions(+), 40 deletions(-)
diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx
index 6a3d648a4..cac644be3 100644
--- a/src/app/[orgId]/settings/layout.tsx
+++ b/src/app/[orgId]/settings/layout.tsx
@@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
-import { orgNavSections } from "@app/app/navigation";
+import { commandBarNavSections, orgNavSections } from "@app/app/navigation";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
export const dynamic = "force-dynamic";
@@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
navItems={orgNavSections(env, {
isPrimaryOrg: primaryOrg
})}
+ commandNavItems={commandBarNavSections(env, {
+ isPrimaryOrg: primaryOrg
+ })}
>
{children}
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index 43323ba2f..e0ada4c1c 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -340,3 +340,253 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
]
}
];
+
+export const commandBarNavSections = (
+ env?: Env,
+ options?: OrgNavSectionsOptions
+): SidebarNavSection[] => [
+ {
+ heading: "network",
+ items: [
+ {
+ title: "sidebarSites",
+ href: "/{orgId}/settings/sites",
+ icon:
+ },
+ {
+ title: "sidebarResources",
+ icon: ,
+ items: [
+ {
+ title: "sidebarProxyResources",
+ href: "/{orgId}/settings/resources/public",
+ icon:
+ },
+ {
+ title: "sidebarClientResources",
+ href: "/{orgId}/settings/resources/private",
+ icon:
+ }
+ ]
+ },
+ {
+ title: "sidebarClients",
+ icon: ,
+ items: [
+ {
+ href: "/{orgId}/settings/clients/user",
+ title: "sidebarUserDevices",
+ icon:
+ },
+ {
+ href: "/{orgId}/settings/clients/machine",
+ title: "sidebarMachineClients",
+ icon:
+ }
+ ]
+ },
+ {
+ title: "sidebarDomains",
+ href: "/{orgId}/settings/domains",
+ icon:
+ },
+ ...(build === "saas"
+ ? [
+ {
+ title: "sidebarRemoteExitNodes",
+ href: "/{orgId}/settings/remote-exit-nodes",
+ icon:
+ }
+ ]
+ : [])
+ ]
+ },
+ {
+ heading: "accessControl",
+ items: [
+ {
+ title: "sidebarTeam",
+ icon: ,
+ items: [
+ {
+ title: "sidebarUsers",
+ href: "/{orgId}/settings/access/users",
+ icon:
+ },
+ {
+ title: "sidebarRoles",
+ href: "/{orgId}/settings/access/roles",
+ icon:
+ },
+ {
+ title: "sidebarInvitations",
+ href: "/{orgId}/settings/access/invitations",
+ icon:
+ }
+ ]
+ },
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "sidebarPolicies",
+
+ icon: ,
+ items: [
+ {
+ title: "sidebarResourcePolicies",
+ href: "/{orgId}/settings/policies/resources/public",
+ icon: (
+
+ )
+ }
+ ]
+ }
+ ]
+ : []),
+ // PaidFeaturesAlert
+ ...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
+ build === "saas" ||
+ env?.app.identityProviderMode === "org" ||
+ (env?.app.identityProviderMode === undefined && build !== "oss")
+ ? [
+ {
+ title: "sidebarIdentityProviders",
+ href: "/{orgId}/settings/idp",
+ icon:
+ }
+ ]
+ : []),
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "sidebarApprovals",
+ href: "/{orgId}/settings/access/approvals",
+ icon:
+ }
+ ]
+ : []),
+ {
+ title: "sidebarShareableLinks",
+ href: "/{orgId}/settings/share-links",
+ icon:
+ }
+ ]
+ },
+ {
+ heading: "sidebarOrganization",
+ items: [
+ {
+ title: "sidebarLogsAndAnalytics",
+ icon: ,
+ items: [
+ {
+ title: "sidebarLogsAnalytics",
+ href: "/{orgId}/settings/logs/analytics",
+ icon:
+ },
+ {
+ title: "sidebarLogsRequest",
+ href: "/{orgId}/settings/logs/request",
+ icon: (
+
+ )
+ },
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "sidebarLogsAccess",
+ href: "/{orgId}/settings/logs/access",
+ icon:
+ },
+ {
+ title: "sidebarLogsAction",
+ href: "/{orgId}/settings/logs/action",
+ icon:
+ },
+ {
+ title: "sidebarLogsConnection",
+ href: "/{orgId}/settings/logs/connection",
+ icon:
+ },
+ {
+ title: "sidebarLogsStreaming",
+ href: "/{orgId}/settings/logs/streaming",
+ icon:
+ }
+ ]
+ : [])
+ ]
+ },
+ {
+ title: "sidebarManagement",
+ icon: ,
+ items: [
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "sidebarAlerting",
+ href: "/{orgId}/settings/alerting",
+ icon: (
+
+ )
+ },
+ {
+ title: "sidebarProvisioning",
+ href: "/{orgId}/settings/provisioning",
+ icon:
+ }
+ ]
+ : []),
+ {
+ title: "sidebarBluePrints",
+ href: "/{orgId}/settings/blueprints",
+ icon:
+ },
+ {
+ title: "sidebarApiKeys",
+ href: "/{orgId}/settings/api-keys",
+ icon:
+ },
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "labels",
+ href: "/{orgId}/settings/labels",
+ icon:
+ }
+ ]
+ : [])
+ ]
+ },
+ ...(build === "saas" && options?.isPrimaryOrg
+ ? [
+ {
+ title: "sidebarBillingAndLicenses",
+ icon: ,
+ items: [
+ {
+ title: "sidebarBilling",
+ href: "/{orgId}/settings/billing",
+ icon: (
+
+ )
+ },
+ {
+ title: "sidebarEnterpriseLicenses",
+ href: "/{orgId}/settings/license",
+ icon: (
+
+ )
+ }
+ ]
+ }
+ ]
+ : []),
+ {
+ title: "sidebarSettings",
+ href: "/{orgId}/settings/general",
+ icon:
+ }
+ ]
+ }
+];
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index c0f814453..8dc95968f 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -13,6 +13,7 @@ interface LayoutProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems?: SidebarNavSection[];
+ commandNavItems?: SidebarNavSection[];
showSidebar?: boolean;
showHeader?: boolean;
showTopBar?: boolean;
@@ -24,6 +25,7 @@ export async function Layout({
orgId,
orgs,
navItems = [],
+ commandNavItems = [],
showSidebar = true,
showHeader = true,
showTopBar = true,
@@ -38,7 +40,11 @@ export async function Layout({
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
-
+
{/* Desktop Sidebar */}
{showSidebar && (
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx
index 8c3036c4a..98b69fa3e 100644
--- a/src/components/SitesTable.tsx
+++ b/src/components/SitesTable.tsx
@@ -113,13 +113,6 @@ export default function SitesTable({
const api = createApiClient(useEnvContext());
const t = useTranslations();
- // useEffect(() => {
- // const interval = setInterval(() => {
- // router.refresh();
- // }, 30_000);
- // return () => clearInterval(interval);
- // }, []);
-
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
index 02556b7dd..b0536d8a9 100644
--- a/src/components/command-palette/CommandPalette.tsx
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -14,7 +14,7 @@ import { Badge } from "@app/components/ui/badge";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
-import {
+import React, {
createContext,
useCallback,
useContext,
@@ -27,6 +27,7 @@ import { useCommandPaletteActions } from "./useCommandPaletteActions";
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations";
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
+import { useUserContext } from "@app/hooks/useUserContext";
type CommandPaletteProps = {
orgId?: string;
@@ -34,6 +35,12 @@ type CommandPaletteProps = {
navItems: SidebarNavSection[];
};
+/**
+ * Plan for command bar:
+ * - the nav items should be custom items instead of all of the ones in the sidebar
+ * - actions should be triggered by using `>` (like in Github)
+ */
+
export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
const t = useTranslations();
const router = useRouter();
@@ -41,7 +48,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
const [search, setSearch] = useState("");
const navigationGroups = useCommandPaletteNavigation(navItems);
- const organizations = useCommandPaletteOrganizations(orgs);
+ // const organizations = useCommandPaletteOrganizations(orgs);
const actions = useCommandPaletteActions(orgId, orgs);
const { shouldSearch, sites, resources, users, machineClients, isLoading } =
useCommandPaletteSearch({
@@ -69,45 +76,58 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
[setOpen]
);
- const hasEntityResults =
- sites.length > 0 ||
- resources.length > 0 ||
- users.length > 0 ||
- machineClients.length > 0;
+ // const hasEntityResults =
+ // sites.length > 0 ||
+ // resources.length > 0 ||
+ // users.length > 0 ||
+ // machineClients.length > 0;
return (
-
+
{t("commandPaletteNoResults")}
- {navigationGroups.map((group) => (
-
- {group.items.map((item) => (
-
- runCommand(() => router.push(item.href))
- }
- >
- {item.icon}
- {item.title}
-
- ))}
-
+
+
+ {navigationGroups.map((group, idx) => (
+
+ {idx > 0 && }
+
+ {group.items.map((item) => (
+
+ runCommand(() => router.push(item.href))
+ }
+ className="h-9"
+ >
+ {item.icon}
+ {item.title}
+
+ ))}
+
+
))}
- {organizations.length > 1 && (
+ {/* {organizations.length > 1 && (
<>
>
- )}
+ )} */}
- {shouldSearch && orgId && (
+ {/* {shouldSearch && orgId && (
<>
{isLoading && !hasEntityResults ? (
@@ -243,12 +263,15 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
>
)}
>
- )}
+ )} */}
- {actions.length > 0 && (
+ {/* {actions.length > 0 && (
<>
-
+
{actions.map((action) => (
>
- )}
+ )} */}
);
From abc0a41d9ecf9a27a023202a729311d748c63310 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Sat, 13 Jun 2026 00:27:39 +0200
Subject: [PATCH 015/309] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20reorganize=20comma?=
=?UTF-8?q?nd=20bar=20items?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 36 ++
src/app/navigation.tsx | 328 +++++++++---------
src/components/Layout.tsx | 7 +-
.../command-palette/CommandPalette.tsx | 27 +-
.../useCommandPaletteNavigation.ts | 6 +-
src/components/ui/command.tsx | 6 +-
6 files changed, 223 insertions(+), 187 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index e18385159..e6567cc08 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1590,6 +1590,42 @@
"sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics",
+ "commandSites": "Sites",
+ "commandActionModeInfo": "Type \">\" to open action mode",
+ "commandResources": "Resources",
+ "commandProxyResources": "Public Resources",
+ "commandClientResources": "Private Resources",
+ "commandClients": "Clients",
+ "commandUserDevices": "User Devices",
+ "commandMachineClients": "Machine Clients",
+ "commandDomains": "Domains",
+ "commandRemoteExitNodes": "Remote Nodes",
+ "commandTeam": "Team",
+ "commandUsers": "Users",
+ "commandRoles": "Roles",
+ "commandInvitations": "Invitations",
+ "commandPolicies": "Shared Policies",
+ "commandResourcePolicies": "Public Resources Policies",
+ "commandIdentityProviders": "Identity Providers",
+ "commandApprovals": "Approval Requests",
+ "commandShareableLinks": "Shareable Links",
+ "commandOrganization": "Organization",
+ "commandLogsAndAnalytics": "Logs & Analytics",
+ "commandLogsAnalytics": "Analytics",
+ "commandLogsRequest": "HTTP Request Logs",
+ "commandLogsAccess": "Authentication Logs",
+ "commandLogsAction": "Admin Action Logs",
+ "commandLogsConnection": "Network Logs",
+ "commandLogsStreaming": "Event Streaming",
+ "commandManagement": "Management",
+ "commandAlerting": "Alerting",
+ "commandProvisioning": "Provisioning",
+ "commandBluePrints": "Blueprints",
+ "commandApiKeys": "API Keys",
+ "commandBillingAndLicenses": "Billing & Licenses",
+ "commandBilling": "Billing",
+ "commandEnterpriseLicenses": "Licenses",
+ "commandSettings": "Settings",
"alertingTitle": "Alerting",
"alertingDescription": "Define sources, triggers, and actions for notifications",
"alertingRules": "Alert rules",
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx
index e0ada4c1c..b3eab82ed 100644
--- a/src/app/navigation.tsx
+++ b/src/app/navigation.tsx
@@ -341,59 +341,56 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
}
];
+export type CommandBarNavSection = {
+ // Added from 'dev' branch
+ heading: string;
+ items: CommandBarNavItem[];
+};
+
+export type CommandBarNavItem = {
+ href?: string;
+ title: string;
+ icon?: React.ReactNode;
+ showEE?: boolean;
+ isBeta?: boolean;
+};
+
export const commandBarNavSections = (
env?: Env,
options?: OrgNavSectionsOptions
-): SidebarNavSection[] => [
+): CommandBarNavSection[] => [
{
heading: "network",
items: [
{
- title: "sidebarSites",
+ title: "commandSites",
href: "/{orgId}/settings/sites",
icon:
},
{
- title: "sidebarResources",
- icon: ,
- items: [
- {
- title: "sidebarProxyResources",
- href: "/{orgId}/settings/resources/public",
- icon:
- },
- {
- title: "sidebarClientResources",
- href: "/{orgId}/settings/resources/private",
- icon:
- }
- ]
- },
- {
- title: "sidebarClients",
- icon: ,
- items: [
- {
- href: "/{orgId}/settings/clients/user",
- title: "sidebarUserDevices",
- icon:
- },
- {
- href: "/{orgId}/settings/clients/machine",
- title: "sidebarMachineClients",
- icon:
- }
- ]
- },
- {
- title: "sidebarDomains",
- href: "/{orgId}/settings/domains",
+ title: "commandProxyResources",
+ href: "/{orgId}/settings/resources/public",
icon:
},
+ {
+ title: "commandClientResources",
+ href: "/{orgId}/settings/resources/private",
+ icon:
+ },
+ {
+ href: "/{orgId}/settings/clients/user",
+ title: "commandUserDevices",
+ icon:
+ },
+ {
+ href: "/{orgId}/settings/clients/machine",
+ title: "commandMachineClients",
+ icon:
+ },
...(build === "saas"
? [
{
- title: "sidebarRemoteExitNodes",
+ title: "commandRemoteExitNodes",
href: "/{orgId}/settings/remote-exit-nodes",
icon:
}
@@ -402,44 +399,34 @@ export const commandBarNavSections = (
]
},
{
- heading: "accessControl",
+ heading: "commandTeam",
items: [
{
- title: "sidebarTeam",
- icon: ,
- items: [
- {
- title: "sidebarUsers",
- href: "/{orgId}/settings/access/users",
- icon:
- },
- {
- title: "sidebarRoles",
- href: "/{orgId}/settings/access/roles",
- icon:
- },
- {
- title: "sidebarInvitations",
- href: "/{orgId}/settings/access/invitations",
- icon:
- }
- ]
+ title: "commandUsers",
+ href: "/{orgId}/settings/access/users",
+ icon:
},
+ {
+ title: "commandRoles",
+ href: "/{orgId}/settings/access/roles",
+ icon:
+ },
+ {
+ title: "commandInvitations",
+ href: "/{orgId}/settings/access/invitations",
+ icon:
+ }
+ ]
+ },
+ {
+ heading: "accessControl",
+ items: [
...(!env?.flags.disableEnterpriseFeatures
? [
{
- title: "sidebarPolicies",
-
- icon: ,
- items: [
- {
- title: "sidebarResourcePolicies",
- href: "/{orgId}/settings/policies/resources/public",
- icon: (
-
- )
- }
- ]
+ title: "commandResourcePolicies",
+ href: "/{orgId}/settings/policies/resources/public",
+ icon:
}
]
: []),
@@ -450,7 +437,7 @@ export const commandBarNavSections = (
(env?.app.identityProviderMode === undefined && build !== "oss")
? [
{
- title: "sidebarIdentityProviders",
+ title: "commandIdentityProviders",
href: "/{orgId}/settings/idp",
icon:
}
@@ -459,134 +446,129 @@ export const commandBarNavSections = (
...(!env?.flags.disableEnterpriseFeatures
? [
{
- title: "sidebarApprovals",
+ title: "commandApprovals",
href: "/{orgId}/settings/access/approvals",
icon:
}
]
: []),
{
- title: "sidebarShareableLinks",
+ title: "commandShareableLinks",
href: "/{orgId}/settings/share-links",
icon:
}
]
},
{
- heading: "sidebarOrganization",
+ heading: "commandLogsAndAnalytics",
items: [
{
- title: "sidebarLogsAndAnalytics",
- icon: ,
- items: [
- {
- title: "sidebarLogsAnalytics",
- href: "/{orgId}/settings/logs/analytics",
- icon:
- },
- {
- title: "sidebarLogsRequest",
- href: "/{orgId}/settings/logs/request",
- icon: (
-
- )
- },
- ...(!env?.flags.disableEnterpriseFeatures
- ? [
- {
- title: "sidebarLogsAccess",
- href: "/{orgId}/settings/logs/access",
- icon:
- },
- {
- title: "sidebarLogsAction",
- href: "/{orgId}/settings/logs/action",
- icon:
- },
- {
- title: "sidebarLogsConnection",
- href: "/{orgId}/settings/logs/connection",
- icon:
- },
- {
- title: "sidebarLogsStreaming",
- href: "/{orgId}/settings/logs/streaming",
- icon:
- }
- ]
- : [])
- ]
+ title: "commandLogsAnalytics",
+ href: "/{orgId}/settings/logs/analytics",
+ icon:
},
{
- title: "sidebarManagement",
- icon: ,
- items: [
- ...(!env?.flags.disableEnterpriseFeatures
- ? [
- {
- title: "sidebarAlerting",
- href: "/{orgId}/settings/alerting",
- icon: (
-
- )
- },
- {
- title: "sidebarProvisioning",
- href: "/{orgId}/settings/provisioning",
- icon:
- }
- ]
- : []),
- {
- title: "sidebarBluePrints",
- href: "/{orgId}/settings/blueprints",
- icon:
- },
- {
- title: "sidebarApiKeys",
- href: "/{orgId}/settings/api-keys",
- icon:
- },
- ...(!env?.flags.disableEnterpriseFeatures
- ? [
- {
- title: "labels",
- href: "/{orgId}/settings/labels",
- icon:
- }
- ]
- : [])
- ]
+ title: "commandLogsRequest",
+ href: "/{orgId}/settings/logs/request",
+ icon:
},
- ...(build === "saas" && options?.isPrimaryOrg
+ ...(!env?.flags.disableEnterpriseFeatures
? [
{
- title: "sidebarBillingAndLicenses",
- icon: ,
- items: [
- {
- title: "sidebarBilling",
- href: "/{orgId}/settings/billing",
- icon: (
-
- )
- },
- {
- title: "sidebarEnterpriseLicenses",
- href: "/{orgId}/settings/license",
- icon: (
-
- )
- }
- ]
+ title: "commandLogsAccess",
+ href: "/{orgId}/settings/logs/access",
+ icon:
+ },
+ {
+ title: "commandLogsAction",
+ href: "/{orgId}/settings/logs/action",
+ icon:
+ },
+ {
+ title: "commandLogsConnection",
+ href: "/{orgId}/settings/logs/connection",
+ icon:
+ },
+ {
+ title: "commandLogsStreaming",
+ href: "/{orgId}/settings/logs/streaming",
+ icon:
+ }
+ ]
+ : [])
+ ]
+ },
+ {
+ heading: "commandManagement",
+ items: [
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "commandAlerting",
+ href: "/{orgId}/settings/alerting",
+ icon:
+ },
+ {
+ title: "commandProvisioning",
+ href: "/{orgId}/settings/provisioning",
+ icon:
}
]
: []),
{
- title: "sidebarSettings",
+ title: "commandBluePrints",
+ href: "/{orgId}/settings/blueprints",
+ icon:
+ },
+ {
+ title: "commandApiKeys",
+ href: "/{orgId}/settings/api-keys",
+ icon:
+ },
+ ...(!env?.flags.disableEnterpriseFeatures
+ ? [
+ {
+ title: "labels",
+ href: "/{orgId}/settings/labels",
+ icon:
+ }
+ ]
+ : [])
+ ]
+ },
+
+ {
+ heading: "commandOrganization",
+ items: [
+ {
+ title: "commandSettings",
href: "/{orgId}/settings/general",
icon:
+ },
+ {
+ title: "commandDomains",
+ href: "/{orgId}/settings/domains",
+ icon:
}
]
- }
+ },
+ ...(build === "saas" && options?.isPrimaryOrg
+ ? [
+ {
+ heading: "commandBillingAndLicenses",
+ items: [
+ {
+ title: "commandBilling",
+ href: "/{orgId}/settings/billing",
+ icon:
+ },
+ {
+ title: "commandEnterpriseLicenses",
+ href: "/{orgId}/settings/license",
+ icon:
+ }
+ ]
+ }
+ ]
+ : [])
];
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 8dc95968f..991c489a7 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -1,7 +1,10 @@
import React from "react";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
-import type { SidebarNavSection } from "@app/app/navigation";
+import type {
+ CommandBarNavSection,
+ SidebarNavSection
+} from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
@@ -13,7 +16,7 @@ interface LayoutProps {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
navItems?: SidebarNavSection[];
- commandNavItems?: SidebarNavSection[];
+ commandNavItems?: CommandBarNavSection[];
showSidebar?: boolean;
showHeader?: boolean;
showTopBar?: boolean;
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
index b0536d8a9..bbf5b9187 100644
--- a/src/components/command-palette/CommandPalette.tsx
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -9,7 +9,10 @@ import {
CommandList,
CommandSeparator
} from "@app/components/ui/command";
-import type { SidebarNavSection } from "@app/app/navigation";
+import type {
+ CommandBarNavSection,
+ SidebarNavSection
+} from "@app/app/navigation";
import { Badge } from "@app/components/ui/badge";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Loader2 } from "lucide-react";
@@ -28,11 +31,12 @@ import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
import { useCommandPaletteOrganizations } from "./useCommandPaletteOrganizations";
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
import { useUserContext } from "@app/hooks/useUserContext";
+import { cn } from "@app/lib/cn";
type CommandPaletteProps = {
orgId?: string;
orgs?: ListUserOrgsResponse["orgs"];
- navItems: SidebarNavSection[];
+ navItems: CommandBarNavSection[];
};
/**
@@ -89,28 +93,35 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
title={t("commandPaletteTitle")}
description={t("commandPaletteDescription")}
className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15"
+ commandProps={{
+ loop: true
+ }}
>
-
+
{t("commandPaletteNoResults")}
- {navigationGroups.map((group, idx) => (
+ {navigationGroups.map((group, groupIndex) => (
- {idx > 0 && }
+ {groupIndex > 0 && }
0 &&
+ "[&_[cmdk-group-heading]]:pt-3"
+ )}
>
- {group.items.map((item) => (
+ {group.items.map((item, itemIndex) => (
;
}) {
return (