- {/* 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 a68b57067c5b93662e18c1da0f9f48d3d41ebff9 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 12 Jun 2026 23:51:05 +0200
Subject: [PATCH 003/655] =?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 004/655] =?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 (
+
+ );
+ })}
{/* {organizations.length > 1 && (
<>
@@ -282,7 +306,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
>
)} */}
- {/* {actions.length > 0 && (
+ {isActionMode && actions.length > 0 && (
<>
>
- )} */}
+ )}
);
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index f4b9e1bfe..efd1c35f5 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,
@@ -68,14 +68,21 @@ function CommandDialog({
function CommandInput({
className,
+ isLoading,
...props
-}: React.ComponentProps) {
+}: React.ComponentProps & {
+ isLoading?: boolean;
+}) {
return (
-
+ {isLoading ? (
+
+ ) : (
+
+ )}
Date: Tue, 16 Jun 2026 22:28:04 +0200
Subject: [PATCH 008/655] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20rename=20query,=20?=
=?UTF-8?q?=20add=20query?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/CreateShareLinkForm.tsx | 2 +-
.../alert-rule-editor/AlertRuleFields.tsx | 2 +-
src/components/resource-selector.tsx | 2 +-
src/lib/queries.ts | 82 ++++++++++++++++++-
4 files changed, 81 insertions(+), 7 deletions(-)
diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx
index 78f705445..95884b486 100644
--- a/src/components/CreateShareLinkForm.tsx
+++ b/src/components/CreateShareLinkForm.tsx
@@ -90,7 +90,7 @@ export default function CreateShareLinkForm({
const t = useTranslations();
const { data: allResources = [] } = useQuery(
- orgQueries.resources({ orgId: org?.org.orgId ?? "" })
+ orgQueries.proxyResources({ orgId: org?.org.orgId ?? "" })
);
const [selectedResource, setSelectedResource] =
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/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/lib/queries.ts b/src/lib/queries.ts
index 7d224c7b1..18e937ceb 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";
@@ -138,6 +142,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,
@@ -282,7 +318,7 @@ export const orgQueries = {
}
}),
- resources: ({
+ proxyResources: ({
orgId,
query,
perPage = 10_000
@@ -292,7 +328,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()
@@ -310,6 +351,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
From 6380079239a599996255a0883348ed2fd9106b35 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Tue, 16 Jun 2026 22:28:31 +0200
Subject: [PATCH 009/655] =?UTF-8?q?=E2=9C=A8=20Command=20palette=20with=20?=
=?UTF-8?q?search=20&=20actions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 1 +
.../command-palette/CommandPalette.tsx | 362 ++++++++++--------
.../useCommandPaletteSearch.ts | 74 +++-
3 files changed, 270 insertions(+), 167 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index e6567cc08..cf32d15fd 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1626,6 +1626,7 @@
"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/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
index 726d1c999..816c83fb3 100644
--- a/src/components/command-palette/CommandPalette.tsx
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -15,6 +15,15 @@ import {
} 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, {
@@ -28,7 +37,7 @@ import React, {
import { useCommandPaletteActions } from "./useCommandPaletteActions";
import { useCommandPaletteNavigation } from "./useCommandPaletteNavigation";
import { useCommandPaletteSearch } from "./useCommandPaletteSearch";
-import { search } from "jmespath";
+import { resources } from "@server/db";
type CommandPaletteProps = {
orgId?: string;
@@ -54,12 +63,21 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
const navigationGroups = useCommandPaletteNavigation(navItems);
// const organizations = useCommandPaletteOrganizations(orgs);
const actions = useCommandPaletteActions(orgId, orgs);
- const { shouldSearch, sites, resources, users, machineClients, isLoading } =
- useCommandPaletteSearch({
- orgId,
- query: search.substring(1),
- enabled: open && isActionMode
- });
+ const {
+ shouldSearch,
+ sites,
+ publicResources,
+ privateResources,
+ users,
+ machineClients,
+ userDevices,
+ isLoading,
+ hasResults: hasEntityResults
+ } = useCommandPaletteSearch({
+ orgId,
+ query: search,
+ enabled: !isActionMode && open
+ });
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
@@ -80,15 +98,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
[setOpen]
);
- // const hasEntityResults =
- // sites.length > 0 ||
- // resources.length > 0 ||
- // users.length > 0 ||
- // machineClients.length > 0;
-
return (
-
+
{t("commandPaletteNoResults")}
{!isActionMode &&
- navigationGroups.map((group, groupIndex) => {
- console.log({
- groupIndex,
- groupHeading: group.heading
- });
- return (
-
- {groupIndex > 0 && }
- 0 &&
- "[&_[cmdk-group-heading]]:pt-3"
- )}
- >
- {group.items.map((item) => (
-
- runCommand(() =>
- router.push(item.href)
- )
- }
- className="h-9"
- >
- {item.icon}
- {item.title}
-
- ))}
-
-
- );
- })}
+ 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 && (
<>
@@ -200,118 +206,157 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
>
)} */}
- {/* {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}
-
-
- ))}
-
- )}
- >
+ {!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) => (
{action.icon}
{action.label}
@@ -387,7 +433,7 @@ export function CommandPaletteProvider({
orgs,
navItems
}: CommandPaletteProviderProps) {
- const [open, setOpen] = useState(false);
+ const [open, setOpen] = useState(true); // FIXME: should be set to `false` by default, this is temporary
const toggle = useCallback(() => {
setOpen((current) => !current);
diff --git a/src/components/command-palette/useCommandPaletteSearch.ts b/src/components/command-palette/useCommandPaletteSearch.ts
index 984892354..7471438d5 100644
--- a/src/components/command-palette/useCommandPaletteSearch.ts
+++ b/src/components/command-palette/useCommandPaletteSearch.ts
@@ -47,7 +47,14 @@ export function useCommandPaletteSearch({
const shouldSearch =
enabled && !!orgId && trimmedQuery.length >= MIN_QUERY_LENGTH;
- const [sitesQuery, resourcesQuery, usersQuery, clientsQuery] = useQueries({
+ const [
+ sitesQuery,
+ proxyResourcesQuery,
+ privateResourcesQuery,
+ usersQuery,
+ clientsQuery,
+ userDevicesQuery
+ ] = useQueries({
queries: [
{
...orgQueries.sites({
@@ -58,7 +65,15 @@ export function useCommandPaletteSearch({
enabled: shouldSearch
},
{
- ...orgQueries.resources({
+ ...orgQueries.proxyResources({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
+ },
+ {
+ ...orgQueries.privateResources({
orgId: orgId ?? "",
query: trimmedQuery,
perPage: SEARCH_PER_PAGE
@@ -80,6 +95,14 @@ export function useCommandPaletteSearch({
perPage: SEARCH_PER_PAGE
}),
enabled: shouldSearch
+ },
+ {
+ ...orgQueries.userDevices({
+ orgId: orgId ?? "",
+ query: trimmedQuery,
+ perPage: SEARCH_PER_PAGE
+ }),
+ enabled: shouldSearch
}
]
});
@@ -93,14 +116,23 @@ export function useCommandPaletteSearch({
}));
}, [orgId, sitesQuery.data]);
- const resources = useMemo((): ResourceSearchResult[] => {
- if (!orgId || !resourcesQuery.data) return [];
- return resourcesQuery.data.map((resource) => ({
+ 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, resourcesQuery.data]);
+ }, [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 [];
@@ -123,20 +155,44 @@ export function useCommandPaletteSearch({
}));
}, [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 ||
- resourcesQuery.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,
- resources,
+ publicResources,
+ privateResources,
users,
machineClients,
- isLoading
+ userDevices,
+ isLoading,
+ hasResults
};
}
From 4e7328a1cc2c37b4fa5f17e648395323dd1659ea Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Tue, 16 Jun 2026 22:29:03 +0200
Subject: [PATCH 010/655] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20private?=
=?UTF-8?q?=20resource=20page?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../settings/resources/private/page.tsx | 45 ++-----------------
src/components/PrivateResourcesTable.tsx | 6 +--
2 files changed, 5 insertions(+), 46 deletions(-)
diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx
index 23ff86296..3cd2507fc 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);
@@ -153,7 +115,6 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
- initialFilterSite={initialFilterSite}
/>
>
diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx
index 2a4d63e85..c7b1fdf9f 100644
--- a/src/components/PrivateResourcesTable.tsx
+++ b/src/components/PrivateResourcesTable.tsx
@@ -117,15 +117,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 {
@@ -142,7 +140,7 @@ export default function PrivateResourcesTable({
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedInternalResource, setSelectedInternalResource] =
- useState();
+ useState(null);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingResource, setEditingResource] =
useState();
From 2ab5540085bfb9415638455848bddcbb4ef903b6 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 19 Jun 2026 18:08:36 +0200
Subject: [PATCH 011/655] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20command=20pa?=
=?UTF-8?q?lette=20trigger=20on=20the=20header?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/components/LayoutHeader.tsx | 2 ++
src/components/LayoutMobileMenu.tsx | 33 ++++++++++---------
.../command-palette/CommandPalette.tsx | 4 +--
3 files changed, 21 insertions(+), 18 deletions(-)
diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx
index 29850f115..5e825a8e2 100644
--- a/src/components/LayoutHeader.tsx
+++ b/src/components/LayoutHeader.tsx
@@ -8,6 +8,7 @@ import { useTheme } from "next-themes";
import BrandingLogo from "./BrandingLogo";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
+import { CommandPaletteTrigger } from "@app/components/command-palette/CommandPaletteTrigger";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -67,6 +68,7 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
{showTopBar && (
diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx
index 13efdd564..8e7d0dfc0 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 { ArrowRight, Menu, Server } 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 } 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;
@@ -121,6 +121,7 @@ export function LayoutMobileMenu({
{showTopBar && (
diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx
index 816c83fb3..514ef6e1a 100644
--- a/src/components/command-palette/CommandPalette.tsx
+++ b/src/components/command-palette/CommandPalette.tsx
@@ -100,7 +100,7 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) {
return (
{
setOpen((current) => !current);
From 8f0e17774f07292d9187f22924e3f9a85d7d8e63 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Mon, 22 Jun 2026 21:22:55 +0200
Subject: [PATCH 012/655] =?UTF-8?q?=F0=9F=92=84=20Update=20command=20bar?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../useCommandPaletteActions.tsx | 25 ++++++-------------
src/components/ui/command.tsx | 3 ++-
2 files changed, 9 insertions(+), 19 deletions(-)
diff --git a/src/components/command-palette/useCommandPaletteActions.tsx b/src/components/command-palette/useCommandPaletteActions.tsx
index 998fb97b8..4b288c3bc 100644
--- a/src/components/command-palette/useCommandPaletteActions.tsx
+++ b/src/components/command-palette/useCommandPaletteActions.tsx
@@ -86,6 +86,12 @@ export function useCommandPaletteActions(
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"),
@@ -98,12 +104,6 @@ export function useCommandPaletteActions(
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({
@@ -129,17 +129,6 @@ export function useCommandPaletteActions(
}
}
- 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"),
@@ -158,4 +147,4 @@ export function useCommandPaletteActions(
return actions;
}, [isAdminPage, orgId, orgs, env, user.serverAdmin, theme, setTheme, t]);
-}
\ No newline at end of file
+}
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
index efd1c35f5..3d577c275 100644
--- a/src/components/ui/command.tsx
+++ b/src/components/ui/command.tsx
@@ -174,10 +174,11 @@ function CommandShortcut({
...props
}: React.ComponentProps<"span">) {
return (
-
Date: Thu, 25 Jun 2026 20:28:57 +0200
Subject: [PATCH 013/655] =?UTF-8?q?=F0=9F=9A=A7=20add=20country=20is=20not?=
=?UTF-8?q?=20rule?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 1 +
server/db/pg/schema/schema.ts | 11 ++++++++++-
server/db/sqlite/schema/schema.ts | 10 +++++++++-
server/lib/blueprints/publicResources.ts | 2 +-
server/lib/blueprints/resourcePolicies.ts | 3 ++-
server/lib/validators.ts | 2 ++
.../PolicyAccessRulesTable.tsx | 19 ++++++++++++++-----
.../policy-access-rule-validation.ts | 1 +
8 files changed, 40 insertions(+), 9 deletions(-)
diff --git a/messages/en-US.json b/messages/en-US.json
index 83e8a488d..3bb70a34f 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -2431,6 +2431,7 @@
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
"exitNode": "Exit Node",
"country": "Country",
+ "countryIsNot": "Country Is Not",
"rulesMatchCountry": "Currently based on source IP",
"region": "Region",
"selectRegion": "Select region",
diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts
index 1b48aa520..934eba1f6 100644
--- a/server/db/pg/schema/schema.ts
+++ b/server/db/pg/schema/schema.ts
@@ -981,7 +981,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match")
- .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
+ .$type<
+ | "CIDR"
+ | "PATH"
+ | "IP"
+ | "COUNTRY"
+ | "COUNTRY_IS_NOT"
+ | "ASN"
+ | "REGION"
+ >()
.notNull(),
value: varchar("value").notNull()
});
@@ -1553,3 +1561,4 @@ export type Label = InferSelectModel;
export type ResourcePolicy = InferSelectModel;
export type RolePolicy = InferSelectModel;
export type UserPolicy = InferSelectModel;
+export type ResourcePolicyRule = InferSelectModel;
diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts
index 0c4a143f5..76e6d22cf 100644
--- a/server/db/sqlite/schema/schema.ts
+++ b/server/db/sqlite/schema/schema.ts
@@ -1252,7 +1252,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match")
- .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
+ .$type<
+ | "CIDR"
+ | "PATH"
+ | "IP"
+ | "COUNTRY"
+ | "COUNTRY_IS_NOT"
+ | "ASN"
+ | "REGION"
+ >()
.notNull(),
value: text("value").notNull()
});
diff --git a/server/lib/blueprints/publicResources.ts b/server/lib/blueprints/publicResources.ts
index 1bbe0a4f1..a6147ea86 100644
--- a/server/lib/blueprints/publicResources.ts
+++ b/server/lib/blueprints/publicResources.ts
@@ -1321,7 +1321,7 @@ function getRuleAction(input: string) {
function getRuleValue(match: string, value: string) {
// if the match is a country, uppercase the value
- if (match == "COUNTRY") {
+ if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase();
}
return value;
diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts
index f8d8d1269..aa5f4bc9b 100644
--- a/server/lib/blueprints/resourcePolicies.ts
+++ b/server/lib/blueprints/resourcePolicies.ts
@@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
function getRuleMatch(
input: string
-): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
+): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
+ | "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
}
diff --git a/server/lib/validators.ts b/server/lib/validators.ts
index ec19bd852..ff0c5fb3d 100644
--- a/server/lib/validators.ts
+++ b/server/lib/validators.ts
@@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"IP",
"PATH",
"COUNTRY",
+ "COUNTRY_IS_NOT",
"ASN",
"REGION"
] as const;
@@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError(
case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY":
+ case "COUNTRY_IS_NOT":
return COUNTRIES.some((country) => country.code === value)
? null
: "Invalid country code provided";
diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx
index a701b92ff..2818f3652 100644
--- a/src/components/resource-policy/PolicyAccessRulesTable.tsx
+++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx
@@ -229,6 +229,7 @@ export function PolicyAccessRulesTable({
IP: "IP",
CIDR: t("ipAddressRange"),
COUNTRY: t("country"),
+ COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN",
REGION: t("region")
}),
@@ -441,13 +442,15 @@ export function PolicyAccessRulesTable({
| "IP"
| "PATH"
| "COUNTRY"
+ | "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, {
match: value,
value:
- value === "COUNTRY"
+ value === "COUNTRY" ||
+ value === "COUNTRY_IS_NOT"
? "US"
: value === "ASN"
? "AS15169"
@@ -469,9 +472,14 @@ export function PolicyAccessRulesTable({
{RuleMatch.CIDR}
{isMaxmindAvailable && (
-
- {RuleMatch.COUNTRY}
-
+ <>
+
+ {RuleMatch.COUNTRY}
+
+
+ {RuleMatch.COUNTRY_IS_NOT}
+
+ >
)}
{includeRegionMatch && isMaxmindAvailable && (
@@ -491,7 +499,8 @@ export function PolicyAccessRulesTable({
accessorKey: "value",
header: () => {t("value")},
cell: ({ row }) =>
- row.original.match === "COUNTRY" ? (
+ row.original.match === "COUNTRY" ||
+ row.original.match === "COUNTRY_IS_NOT" ? (
);
From 05bf77da295aaf2a93c3cfb4dbd5f4161898e949 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 3 Jul 2026 21:05:47 +0200
Subject: [PATCH 022/655] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20`cache`?=
=?UTF-8?q?=20on=20`verifySession`=20calls=20as=20it's=20already=20wrapped?=
=?UTF-8?q?=20in=20`cache`?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/auth/login/page.tsx | 3 +--
src/app/page.tsx | 5 +----
2 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index 274cab561..cdb05c71d 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -29,8 +29,7 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
- const getUser = cache(verifySession);
- const user = await getUser({ skipCheckVerifyEmail: true });
+ const user = await verifySession({ skipCheckVerifyEmail: true });
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 7f0f05b57..84a14cb5c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
-import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
-import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -27,8 +25,7 @@ export default async function Page(props: {
const env = pullEnv();
- const getUser = cache(verifySession);
- const user = await getUser({ skipCheckVerifyEmail: true });
+ const user = await verifySession({ skipCheckVerifyEmail: true });
let complete = false;
try {
From a74c0c227c1df7d3909fe48223f82a05053a85f3 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 3 Jul 2026 21:05:53 +0200
Subject: [PATCH 023/655] =?UTF-8?q?=F0=9F=8C=90=20text?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
messages/en-US.json | 1 +
1 file changed, 1 insertion(+)
diff --git a/messages/en-US.json b/messages/en-US.json
index 4701d4da1..5d774c50b 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1503,6 +1503,7 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with",
+ "idpLastUsed": "Last used",
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",
From 289be30e6b17a28f4dd216889e1e39508cae3c50 Mon Sep 17 00:00:00 2001
From: Fred KISSIE
Date: Fri, 3 Jul 2026 22:33:51 +0200
Subject: [PATCH 024/655] =?UTF-8?q?=E2=9C=A8=20show=20last=20used=20login?=
=?UTF-8?q?=20idp=20in=20smart=20login=20form?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/auth/login/page.tsx | 44 ++++++++++++++--
src/components/IdpLoginButtons.tsx | 80 ++++++++++++++++++++----------
src/components/SmartLoginForm.tsx | 15 +++++-
src/lib/consts.ts | 1 +
src/lib/setClientCookie.ts | 32 ++++++++++++
5 files changed, 139 insertions(+), 33 deletions(-)
create mode 100644 src/lib/consts.ts
create mode 100644 src/lib/setClientCookie.ts
diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx
index cdb05c71d..92097512c 100644
--- a/src/app/auth/login/page.tsx
+++ b/src/app/auth/login/page.tsx
@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm";
-import { ListIdpsResponse } from "@server/routers/idp";
+import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
import type { Metadata } from "next";
+import { cookies } from "next/headers";
+import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
+import z from "zod";
export const metadata: Metadata = {
title: "Log In"
@@ -31,6 +34,8 @@ export default async function Page(props: {
const searchParams = await props.searchParams;
const user = await verifySession({ skipCheckVerifyEmail: true });
+ const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
+
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
const forceLogin = forceLoginParam === "true";
@@ -84,19 +89,47 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
+ let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
+ null;
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") {
- const idpsRes = await cache(
- async () =>
- await priv.get>("/idp")
- )();
+ const idpsRes =
+ await priv.get>("/idp");
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) as LoginFormIDP[];
}
+ } else {
+ if (lastUsedIdpCookie) {
+ const lastUsedIdpSchema = z.object({
+ orgId: z.string().optional(),
+ idpId: z.number()
+ });
+ try {
+ const persistedData = lastUsedIdpSchema.parse(
+ JSON.parse(lastUsedIdpCookie.value)
+ );
+
+ const idpRes = await priv.get>(
+ `/idp/${persistedData.idpId}`
+ );
+
+ const idp = idpRes.data.data.idp;
+
+ lastUsedIdpForSmartLogin = {
+ idpId: idp.idpId,
+ name: idp.name,
+ variant: idp.type,
+ orgId: persistedData.orgId,
+ lastUsed: true
+ };
+ } catch (error) {
+ // the idp might not exist or the data is malformatted, skip this
+ }
+ }
}
const t = await getTranslations();
@@ -159,6 +192,7 @@ export default async function Page(props: {
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
+ lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={
!isInvite &&
(build === "saas" ||
diff --git a/src/components/IdpLoginButtons.tsx b/src/components/IdpLoginButtons.tsx
index d015dce52..0851a0dd0 100644
--- a/src/components/IdpLoginButtons.tsx
+++ b/src/components/IdpLoginButtons.tsx
@@ -1,26 +1,25 @@
"use client";
-import { useEffect, useState } from "react";
-import { Button } from "@app/components/ui/button";
-import { Alert, AlertDescription } from "@app/components/ui/alert";
-import { useTranslations } from "next-intl";
+import { generateOidcUrlProxy } from "@app/actions/server";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
-import {
- generateOidcUrlProxy,
- type GenerateOidcUrlResponse
-} from "@app/actions/server";
+import { Alert, AlertDescription } from "@app/components/ui/alert";
+import { Button } from "@app/components/ui/button";
+import { cleanRedirect } from "@app/lib/cleanRedirect";
+import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
+import { setClientCookie } from "@app/lib/setClientCookie";
+import { useTranslations } from "next-intl";
import {
redirect as redirectTo,
- useParams,
+ useRouter,
useSearchParams
} from "next/navigation";
-import { useRouter } from "next/navigation";
-import { cleanRedirect } from "@app/lib/cleanRedirect";
+import { useEffect, useState, useTransition } from "react";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
+ lastUsed?: boolean;
};
type IdpLoginButtonsProps = {
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
orgId
}: IdpLoginButtonsProps) {
const [error, setError] = useState(null);
- const [loading, setLoading] = useState(false);
const t = useTranslations();
const params = useSearchParams();
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
}
}, []);
+ const [loading, startTransition] = useTransition();
+
async function loginWithIdp(idpId: number) {
- setLoading(true);
setError(null);
+ setClientCookie(
+ LAST_USED_IDP_COOKIE_NAME,
+ JSON.stringify({
+ orgId,
+ idpId
+ }),
+ {
+ sameSite: "Lax"
+ }
+ );
+
let redirectToUrl: string | undefined;
try {
console.log("generating", idpId, redirect || "/", orgId);
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
if (response.error) {
setError(response.message);
- setLoading(false);
return;
}
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again."
})
);
- setLoading(false);
}
if (redirectToUrl) {
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase();
return (
- {
- loginWithIdp(idp.idpId);
- }}
- disabled={loading}
- loading={loading}
>
-
- {idp.name}
-
+ {
+ startTransition(() =>
+ loginWithIdp(idp.idpId)
+ );
+ }}
+ disabled={loading}
+ loading={loading}
+ >
+
+ {idp.name}
+
+
+ {idp.lastUsed && (
+
+
+ {t("idpLastUsed")}
+
+
+ )}
+
);
})}
>
diff --git a/src/components/SmartLoginForm.tsx b/src/components/SmartLoginForm.tsx
index dd0e131df..de1955771 100644
--- a/src/components/SmartLoginForm.tsx
+++ b/src/components/SmartLoginForm.tsx
@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink";
+import type { LoginFormIDP } from "./LoginForm";
+import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required")
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
forceLogin?: boolean;
defaultUser?: string;
orgSignIn?: OrgSignInConfig;
+ lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
};
type ViewState =
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
redirect,
forceLogin,
defaultUser,
- orgSignIn
+ orgSignIn,
+ lastUsedIdp
}: SmartLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
+
+ {lastUsedIdp && (
+