diff --git a/messages/en-US.json b/messages/en-US.json
index 027d9fc38..7891a67f7 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1354,6 +1354,29 @@
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",
+ "commandPaletteTitle": "Command palette",
+ "commandPaletteDescription": "Search for pages, organizations, resources, and actions",
+ "commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
+ "commandPaletteNoResults": "No results found.",
+ "commandPaletteSearching": "Searching...",
+ "commandPaletteNavigation": "Navigation",
+ "commandPaletteOrganizations": "Organizations",
+ "commandPaletteSites": "Sites",
+ "commandPaletteResources": "Resources",
+ "commandPaletteUsers": "Users",
+ "commandPaletteClients": "Machine clients",
+ "commandPaletteActions": "Actions",
+ "commandPaletteCreateSite": "Create site",
+ "commandPaletteCreateProxyResource": "Create public resource",
+ "commandPaletteCreateUser": "Create user",
+ "commandPaletteCreateApiKey": "Create API key",
+ "commandPaletteCreateMachineClient": "Create machine client",
+ "commandPaletteCreateAlertRule": "Create alert rule",
+ "commandPaletteCreateIdentityProvider": "Create identity provider",
+ "commandPaletteToggleTheme": "Toggle theme",
+ "commandPaletteChooseOrganization": "Choose organization",
+ "commandPaletteShortcutMac": "⌘K",
+ "commandPaletteShortcutWindows": "Ctrl K",
"navbarDocsLink": "Documentation",
"otpErrorEnable": "Unable to enable 2FA",
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
diff --git a/src/app/page.tsx b/src/app/page.tsx
index f6f30276a..6b18016c6 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -21,9 +21,11 @@ export default async function Page(props: {
searchParams: Promise<{
redirect: string | undefined;
t: string | undefined;
+ orgs?: string | undefined;
}>;
}) {
const params = await props.searchParams; // this is needed to prevent static optimization
+ const showOrgPicker = params.orgs === "1";
const env = pullEnv();
@@ -106,7 +108,7 @@ export default async function Page(props: {
}
}
- if (targetOrgId) {
+ if (targetOrgId && !showOrgPicker) {
return ;
}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index dd0ef3d2f..95ce43120 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -5,6 +5,7 @@ import type { SidebarNavSection } from "@app/app/navigation";
import { LayoutSidebar } from "@app/components/LayoutSidebar";
import { LayoutHeader } from "@app/components/LayoutHeader";
import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu";
+import { CommandPaletteProvider } from "@app/components/command-palette/CommandPaletteProvider";
import { cookies } from "next/headers";
interface LayoutProps {
@@ -37,51 +38,53 @@ export async function Layout({
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
-
- {/* 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/LayoutHeader.tsx b/src/components/LayoutHeader.tsx
index 29850f115..f1096796c 100644
--- a/src/components/LayoutHeader.tsx
+++ b/src/components/LayoutHeader.tsx
@@ -6,6 +6,7 @@ import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
+import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
@@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
{showTopBar && (
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx
index 13efdd564..ff4815ce8 100644
--- a/src/components/LayoutMobileMenu.tsx
+++ b/src/components/LayoutMobileMenu.tsx
@@ -13,6 +13,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
import { useTranslations } from "next-intl";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
+import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
import type { SidebarNavSection } from "@app/app/navigation";
import {
Sheet,
@@ -121,6 +122,7 @@ export function LayoutMobileMenu({
{showTopBar && (
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
new file mode 100644
index 000000000..c915aedaf
--- /dev/null
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -0,0 +1,270 @@
+"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 { useCallback, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useCommandPalette } from "./commandPaletteContext";
+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}
+
+ ))}
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/command-palette/CommandPaletteProvider.tsx b/src/components/command-palette/CommandPaletteProvider.tsx
new file mode 100644
index 000000000..c3a6bbf0c
--- /dev/null
+++ b/src/components/command-palette/CommandPaletteProvider.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import type { SidebarNavSection } from "@app/app/navigation";
+import { ListUserOrgsResponse } from "@server/routers/org";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ CommandPaletteContextProvider,
+ type CommandPaletteContextValue
+} from "./commandPaletteContext";
+import { CommandPalette } from "./CommandPalette";
+
+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..f4dd0bdef
--- /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 "./commandPaletteContext";
+
+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/commandPaletteContext.tsx b/src/components/command-palette/commandPaletteContext.tsx
new file mode 100644
index 000000000..26de0f2e6
--- /dev/null
+++ b/src/components/command-palette/commandPaletteContext.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import React, { createContext, useContext } from "react";
+
+export type CommandPaletteContextValue = {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ toggle: () => void;
+};
+
+const CommandPaletteContext = createContext(
+ null
+);
+
+export function CommandPaletteContextProvider({
+ value,
+ children
+}: {
+ value: CommandPaletteContextValue;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCommandPalette() {
+ const context = useContext(CommandPaletteContext);
+ if (!context) {
+ throw new Error(
+ "useCommandPalette must be used within CommandPaletteProvider"
+ );
+ }
+ return context;
+}
diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx
new file mode 100644
index 000000000..a4a65d382
--- /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]);
+}
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
+ };
+}