diff --git a/messages/en-US.json b/messages/en-US.json index 323214910..576585891 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1519,6 +1519,29 @@ "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", + "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", "otpErrorEnable": "Unable to enable 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA", "otpSetupCheckCode": "Please enter a 6-digit code", @@ -1596,6 +1619,43 @@ "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", + "commandSearchResults": "Search Results", "alertingTitle": "Alerting", "alertingDescription": "Define sources, triggers, and actions for notifications", "alertingRules": "Alert rules", 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/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 10ef118f2..8206d14e8 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -1,18 +1,15 @@ +import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; -import type { ListResourcesResponse } from "@server/routers/resource"; -import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; -import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; -import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; export const metadata: Metadata = { @@ -24,13 +21,6 @@ export interface ClientResourcesPageProps { searchParams: Promise>; } -function parsePositiveInt(s: string | undefined): number | undefined { - if (!s) return undefined; - const n = Number(s); - if (!Number.isInteger(n) || n <= 0) return undefined; - return n; -} - export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -39,7 +29,7 @@ export default async function ClientResourcesPage( const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - let pagination: ListResourcesResponse["pagination"] = { + let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = { total: 0, page: 1, pageSize: 20 @@ -56,34 +46,6 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} - const siteIdParam = parsePositiveInt( - searchParams.get("siteId") ?? undefined - ); - - let initialFilterSite: { - siteId: number; - name: string; - type: string; - } | null = null; - if (siteIdParam) { - try { - const siteRes = await internal.get( - `/site/${siteIdParam}`, - await authCookieHeader() - ); - const s = (siteRes.data as ResponseT).data; - if (s && s.orgId === params.orgId) { - initialFilterSite = { - siteId: s.siteId, - name: s.name, - type: s.type - }; - } - } catch { - // leave null - } - } - let org = null; try { const res = await getCachedOrg(params.orgId); @@ -154,7 +116,6 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} - initialFilterSite={initialFilterSite} /> diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 853a2df98..04c5ee684 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -341,3 +341,235 @@ 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 +): CommandBarNavSection[] => [ + { + heading: "network", + items: [ + { + title: "commandSites", + href: "/{orgId}/settings/sites", + icon: + }, + { + 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: "commandRemoteExitNodes", + href: "/{orgId}/settings/remote-exit-nodes", + icon: + } + ] + : []) + ] + }, + { + heading: "commandTeam", + items: [ + { + 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: "commandResourcePolicies", + 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: "commandIdentityProviders", + href: "/{orgId}/settings/idp", + icon: + } + ] + : []), + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + title: "commandApprovals", + href: "/{orgId}/settings/access/approvals", + icon: + } + ] + : []), + { + title: "commandShareableLinks", + href: "/{orgId}/settings/share-links", + icon: + } + ] + }, + { + heading: "commandLogsAndAnalytics", + items: [ + { + title: "commandLogsAnalytics", + href: "/{orgId}/settings/logs/analytics", + icon: + }, + { + title: "commandLogsRequest", + href: "/{orgId}/settings/logs/request", + icon: + }, + ...(!env?.flags.disableEnterpriseFeatures + ? [ + { + 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: "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/app/page.tsx b/src/app/page.tsx index 7f0f05b57..27c2c8eb3 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) { const targetOrg = orgs.find((org) => org.orgId === targetOrgId); 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 434963882..cd130f5fe 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -12,6 +12,7 @@ import { ListUserOrgsResponse } from "@server/routers/org"; import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector"; import { Button } from "@app/components/ui/button"; import { useTranslations } from "next-intl"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; type LayoutHeaderProps = { showTopBar: boolean; @@ -104,6 +105,7 @@ export function LayoutHeader({ {showTopBar && (
+
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 7d7ebb902..52536820e 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -1,27 +1,27 @@ "use client"; -import React, { useState } from "react"; -import { SidebarNav } from "@app/components/SidebarNav"; -import { OrgSelector } from "@app/components/OrgSelector"; -import { cn } from "@app/lib/cn"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import { Button } from "@app/components/ui/button"; -import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; -import ProfileIcon from "@app/components/ProfileIcon"; -import ThemeSwitcher from "@app/components/ThemeSwitcher"; import type { SidebarNavSection } from "@app/app/navigation"; +import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger"; +import { OrgSelector } from "@app/components/OrgSelector"; +import ProfileIcon from "@app/components/ProfileIcon"; +import { SidebarNav } from "@app/components/SidebarNav"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { Button } from "@app/components/ui/button"; import { Sheet, SheetContent, - SheetTrigger, + SheetDescription, SheetTitle, - SheetDescription + SheetTrigger } from "@app/components/ui/sheet"; -import { Abel } from "next/font/google"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; interface LayoutMobileMenuProps { orgId?: string; @@ -207,6 +207,7 @@ export function LayoutMobileMenu({ {showTopBar && (
+
diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index 4c1e63f54..8d6f1a014 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -119,15 +119,13 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; - initialFilterSite?: Selectedsite | null; }; export default function PrivateResourcesTable({ internalResources, orgId, pagination, - rowCount, - initialFilterSite = null + rowCount }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -145,7 +143,11 @@ export default function PrivateResourcesTable({ const [isNavigatingToAddPage, startNavigation] = useTransition(); const [selectedInternalResource, setSelectedInternalResource] = + useState(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingResource, setEditingResource] = useState(); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isRefreshing, startRefreshTransition] = useTransition(); diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index d787595ed..7ae6d0a04 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -348,7 +348,7 @@ function ResourceMultiSelect({ const [debounced] = useDebounce(q, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ orgId, query: debounced, perPage: 10 }) + orgQueries.proxyResources({ orgId, query: debounced, perPage: 10 }) ); const shown = useMemo(() => { diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx new file mode 100644 index 000000000..514ef6e1a --- /dev/null +++ b/src/components/command-palette/CommandPalette.tsx @@ -0,0 +1,478 @@ +"use client"; + +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@app/components/ui/command"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { + ChevronRightIcon, + GlobeIcon, + GlobeLockIcon, + LaptopIcon, + PlugIcon, + ServerIcon, + UserIcon +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from "react"; +import { useCommandPaletteActions } from "./useCommandPaletteActions"; +import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation"; +import { useCommandPaletteSearch } from "./useCommandPaletteSearch"; +import { resources } from "@server/db"; + +type CommandPaletteProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: CommandBarNavSection[]; +}; + +/** + * 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) + * -> if search starts with `>`, the filter should exclude that char in the filter string + */ + +export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { + const t = useTranslations(); + const router = useRouter(); + const { open, setOpen } = useCommandPalette(); + const [search, setSearch] = useState(""); + + const isActionMode = search.startsWith(">"); + + const navigationGroups = useCommandPaletteNavigation(navItems); + // const organizations = useCommandPaletteOrganizations(orgs); + const actions = useCommandPaletteActions(orgId, orgs); + const { + shouldSearch, + sites, + publicResources, + privateResources, + users, + machineClients, + userDevices, + isLoading, + hasResults: hasEntityResults + } = useCommandPaletteSearch({ + orgId, + query: search, + enabled: !isActionMode && open + }); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) { + setSearch(""); + } + }, + [setOpen] + ); + + const runCommand = useCallback( + (command: () => void) => { + setOpen(false); + setSearch(""); + command(); + }, + [setOpen] + ); + + return ( + ")) { + search = query.substring(1); + + console.log({ + search, + value + }); + } + + if ( + value + .toLowerCase() + .includes(search.trim().toLowerCase()) + ) { + return 1; + } + return 0; + } + }} + > + + + {t("commandPaletteNoResults")} + + + + {!isActionMode && + navigationGroups.map((group, groupIndex) => ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} + > + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ))} + + {/* {organizations.length > 1 && ( + <> + + + {organizations.map((org) => ( + + runCommand(() => router.push(org.href)) + } + > + {org.name} + + {org.orgId} + + {org.isPrimaryOrg && ( + + {t("primary")} + + )} + + ))} + + + )} */} + + {!isActionMode && shouldSearch && orgId && hasEntityResults && ( + + {sites.map((site) => ( + + runCommand(() => router.push(site.href)) + } + className="h-9" + > +
+ + + {t("commandSites")} + + + + {site.name} + +
+
+ ))} + {publicResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandProxyResources")} + + + + {resource.name} + +
+
+ ))} + {privateResources.map((resource) => ( + + runCommand(() => router.push(resource.href)) + } + className="h-9" + > +
+ + + {t("commandClientResources")} + + + + {resource.name} + +
+
+ ))} + {users.map((user) => ( + + runCommand(() => router.push(user.href)) + } + className="h-9" + > +
+ + + {t("commandUsers")} + + +
+ + {user.name} + + + · + + + {user.email} + +
+
+
+ ))} + {machineClients.map((client) => ( + + runCommand(() => router.push(client.href)) + } + className="h-9" + > +
+ + + {t("commandMachineClients")} + + + + {client.name} + +
+
+ ))} + {userDevices.map((device) => ( + + runCommand(() => router.push(device.href)) + } + className="h-9" + > +
+ + + {t("commandUserDevices")} + + + + {device.name} + +
+
+ ))} +
+ )} + + {isActionMode && actions.length > 0 && ( + <> + + {actions.map((action) => ( + + runCommand(() => { + if (action.onSelect) { + action.onSelect(); + } else if (action.href) { + router.push(action.href); + } + }) + } + className="h-9" + > + {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..4b288c3bc --- /dev/null +++ b/src/components/command-palette/useCommandPaletteActions.tsx @@ -0,0 +1,150 @@ +"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-machine-client", + label: t("commandPaletteCreateMachineClient"), + icon: , + href: `/${orgId}/settings/clients/machine/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` + }); + + 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` + }); + } + } + + 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..1b09c08d6 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteNavigation.ts @@ -0,0 +1,57 @@ +"use client"; + +import type { CommandBarNavSection } from "@app/app/navigation"; +import { flattenNavSections } from "@app/lib/flattenNavItems"; +import { + hydrateNavHref, + navHrefParamsFromRoute +} from "@app/lib/hydrateNavHref"; +import { useTranslations } from "next-intl"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; + +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: CommandBarNavSection[] +): 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..7471438d5 --- /dev/null +++ b/src/components/command-palette/useCommandPaletteSearch.ts @@ -0,0 +1,198 @@ +"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, + proxyResourcesQuery, + privateResourcesQuery, + usersQuery, + clientsQuery, + userDevicesQuery + ] = useQueries({ + queries: [ + { + ...orgQueries.sites({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.proxyResources({ + orgId: orgId ?? "", + query: trimmedQuery, + perPage: SEARCH_PER_PAGE + }), + enabled: shouldSearch + }, + { + ...orgQueries.privateResources({ + 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 + }, + { + ...orgQueries.userDevices({ + 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 publicResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !proxyResourcesQuery.data) return []; + return proxyResourcesQuery.data.map((resource) => ({ + id: `resource-${resource.resourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/proxy/${resource.niceId}` + })); + }, [orgId, proxyResourcesQuery.data]); + + const privateResources = useMemo((): ResourceSearchResult[] => { + if (!orgId || !privateResourcesQuery.data) return []; + return privateResourcesQuery.data.map((resource) => ({ + id: `site-resource-${resource.siteResourceId}`, + name: resource.name, + href: `/${orgId}/settings/resources/private?query=${resource.name}` + })); + }, [orgId, privateResourcesQuery.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 userDevices = useMemo((): ClientSearchResult[] => { + if (!orgId || !userDevicesQuery.data) return []; + return userDevicesQuery.data + .filter((client) => !client.userId) + .map((client) => ({ + id: `client-${client.clientId}`, + name: client.name, + href: `/${orgId}/settings/clients/user/${client.niceId}` + })); + }, [orgId, userDevicesQuery.data]); + + const isLoading = + shouldSearch && + (sitesQuery.isFetching || + proxyResourcesQuery.isFetching || + privateResourcesQuery.isFetching || + usersQuery.isFetching || + userDevicesQuery.isFetching || + clientsQuery.isFetching); + + const hasResults = + sites.length > 0 || + publicResources.length > 0 || + users.length > 0 || + privateResources.length > 0 || + userDevices.length > 0 || + machineClients.length > 0; + + return { + debouncedQuery: trimmedQuery, + shouldSearch, + sites, + publicResources, + privateResources, + users, + machineClients, + userDevices, + isLoading, + hasResults + }; +} diff --git a/src/components/resource-selector.tsx b/src/components/resource-selector.tsx index b01263333..625bb2d64 100644 --- a/src/components/resource-selector.tsx +++ b/src/components/resource-selector.tsx @@ -39,7 +39,7 @@ export function ResourceSelector({ const [debouncedSearchQuery] = useDebounce(resourceSearchQuery, 150); const { data: resources = [] } = useQuery( - orgQueries.resources({ + orgQueries.proxyResources({ orgId: orgId, query: debouncedSearchQuery, perPage: 10 diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index eab0f517a..3d577c275 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; -import { SearchIcon } from "lucide-react"; +import { LoaderIcon, SearchIcon } from "lucide-react"; import { Dialog, @@ -41,6 +41,7 @@ function CommandDialog({ description?: string; className?: string; showCloseButton?: boolean; + commandProps?: React.ComponentProps; }) { return ( @@ -48,8 +49,16 @@ function CommandDialog({ {title} {description} - - + + {children} @@ -59,14 +68,21 @@ function CommandDialog({ function CommandInput({ className, + isLoading, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + isLoading?: boolean; +}) { return (
- + {isLoading ? ( + + ) : ( + + )} ) { return ( - +): 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 + }; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index a086963ed..8fdb1c531 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -7,7 +7,10 @@ import type { QueryConnectionAuditLogResponse, QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; -import type { ListClientsResponse } from "@server/routers/client"; +import type { + ListClientsResponse, + ListUserDevicesResponse +} from "@server/routers/client"; import type { GetDNSRecordsResponse, ListDomainsResponse @@ -25,6 +28,7 @@ import type { import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; import type { + ListAllSiteResourcesByOrgResponse, ListSiteResourceClientsResponse, ListSiteResourceRolesResponse, ListSiteResourceUsersResponse @@ -38,7 +42,7 @@ import { queryOptions } from "@tanstack/react-query"; import type { AxiosResponse } from "axios"; -import z from "zod"; +import z, { meta } from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; import type { ListOrgLabelsResponse } from "@server/routers/labels/types"; @@ -185,6 +189,38 @@ export const orgQueries = { return res.data.data.clients; } }), + userDevices: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "USER_DEVICES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/user-devices?${sp.toString()}`, { signal }); + + return res.data.data.devices; + } + }), users: ({ orgId, query, @@ -329,7 +365,7 @@ export const orgQueries = { } }), - resources: ({ + proxyResources: ({ orgId, query, perPage = 10_000 @@ -339,7 +375,12 @@ export const orgQueries = { perPage?: number; }) => queryOptions({ - queryKey: ["ORG", orgId, "RESOURCES", { query, perPage }] as const, + queryKey: [ + "ORG", + orgId, + "PROXY_RESOURCES", + { query, perPage } + ] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ pageSize: perPage.toString() @@ -357,6 +398,39 @@ export const orgQueries = { } }), + privateResources: ({ + orgId, + query, + perPage = 10_000 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "PRIVATE_RESOURCES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/site-resources?${sp.toString()}`, { signal }); + + return res.data.data.siteResources; + } + }), + healthChecks: ({ orgId, perPage = 10_000