From 35d16ac6835b8b3dc68e302d711136715b2fce48 Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:53:10 +0530 Subject: [PATCH 01/53] fix: redirect lost after Pangolin auth session timeout (#3001) --- messages/en-US.json | 3 + src/app/auth/resource/[resourceGuid]/page.tsx | 2 + src/components/OrgPolicyRequired.tsx | 60 +++++++++++++++++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 4a3937d5b..4920dd8e4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2401,6 +2401,9 @@ "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "additionalSecurityRequired": "Additional Security Required", "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", + "sessionExpired": "Session Expired", + "sessionExpiredReauthRequired": "Your session has expired per your organization's security policy. Please re-authenticate to continue.", + "reauthenticate": "Re-authenticate", "completeTheseSteps": "Complete these steps", "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index c78c277b6..c3f604476 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -166,11 +166,13 @@ export default async function ResourceAuthPage(props: { // If user is not compliant with org policies, show policy requirements if (orgPolicyCheck && !orgPolicyCheck.allowed && orgPolicyCheck.policies) { + const resourceAuthPageUrl = `/auth/resource/${authInfo.resourceGuid}${redirectUrl !== authInfo.url ? `?redirect=${encodeURIComponent(redirectUrl)}` : ""}`; return (
); diff --git a/src/components/OrgPolicyRequired.tsx b/src/components/OrgPolicyRequired.tsx index efc3d5321..444be5a46 100644 --- a/src/components/OrgPolicyRequired.tsx +++ b/src/components/OrgPolicyRequired.tsx @@ -11,24 +11,76 @@ import { import { Shield, ArrowRight } from "lucide-react"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { api } from "@app/lib/api"; type OrgPolicyRequiredProps = { orgId: string; policies: { requiredTwoFactor?: boolean; + maxSessionLength?: { + compliant: boolean; + maxSessionLengthHours: number; + sessionAgeHours: number; + }; + passwordAge?: { + compliant: boolean; + maxPasswordAgeDays: number; + passwordAgeDays: number; + }; }; + redirectAfterAuth?: string; }; export default function OrgPolicyRequired({ orgId, - policies + policies, + redirectAfterAuth }: OrgPolicyRequiredProps) { const t = useTranslations(); + const router = useRouter(); - const policySteps = []; + const sessionExpired = + policies?.maxSessionLength && + policies.maxSessionLength.compliant === false; - if (policies?.requiredTwoFactor === false) { - policySteps.push(t("enableTwoFactorAuthentication")); + function reauthenticate() { + api.post("/auth/logout") + .catch(() => {}) + .then(() => { + const destination = redirectAfterAuth ?? `/${orgId}`; + router.push(destination); + router.refresh(); + }); + } + + if (sessionExpired) { + return ( + + +
+ +
+ + {t("sessionExpired")} + + + {t("sessionExpiredReauthRequired")} + +
+ +
+ +
+
+
+ ); } return ( From 444d293a29575402575c16e92955df1033f7b30a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 23:46:11 +0200 Subject: [PATCH 02/53] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20copy=20command=20ba?= =?UTF-8?q?r=20from=20existing=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 23 ++ src/app/page.tsx | 4 +- src/components/Layout.tsx | 79 ++-- .../command-palette/CommandPalette.tsx | 368 ++++++++++++++++++ .../command-palette/CommandPaletteTrigger.tsx | 69 ++++ .../useCommandPaletteActions.tsx | 161 ++++++++ .../useCommandPaletteNavigation.ts | 57 +++ .../useCommandPaletteOrganizations.ts | 45 +++ .../useCommandPaletteSearch.ts | 142 +++++++ src/lib/flattenNavItems.ts | 42 ++ src/lib/hydrateNavHref.ts | 35 ++ 11 files changed, 986 insertions(+), 39 deletions(-) create mode 100644 src/components/command-palette/CommandPalette.tsx create mode 100644 src/components/command-palette/CommandPaletteTrigger.tsx create mode 100644 src/components/command-palette/useCommandPaletteActions.tsx create mode 100644 src/components/command-palette/useCommandPaletteNavigation.ts create mode 100644 src/components/command-palette/useCommandPaletteOrganizations.ts create mode 100644 src/components/command-palette/useCommandPaletteSearch.ts create mode 100644 src/lib/flattenNavItems.ts create mode 100644 src/lib/hydrateNavHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b5..e18385159 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1490,6 +1490,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", diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..d662acb22 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -21,9 +21,11 @@ export default async function Page(props: { searchParams: Promise<{ redirect: string | undefined; t: string | undefined; + orgs?: string | undefined; }>; }) { const params = await props.searchParams; // this is needed to prevent static optimization + const showOrgPicker = params.orgs === "1"; const env = pullEnv(); @@ -106,7 +108,7 @@ export default async function Page(props: { } } - if (targetOrgId) { + if (targetOrgId && !showOrgPicker) { return ; } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..c0f814453 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { LayoutSidebar } from "@app/components/LayoutSidebar"; import { LayoutHeader } from "@app/components/LayoutHeader"; import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; import { cookies } from "next/headers"; +import { CommandPaletteProvider } from "./command-palette/CommandPalette"; interface LayoutProps { children: React.ReactNode; @@ -37,51 +38,53 @@ export async function Layout({ (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Desktop Sidebar */} - {showSidebar && ( - - )} - - {/* Main content area */} -
- {/* Mobile header */} - {showHeader && ( - +
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} - {/* Desktop header */} - {showHeader && } + {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + + )} - {/* Main content */} -
-
- {children} -
-
+ {/* Desktop header */} + {showHeader && } + + {/* Main content */} +
+
+ {children} +
+
+
-
+ ); } diff --git a/src/components/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 03/53] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20Use=20command=20bar?= =?UTF-8?q?=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 04/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20reorganize=20command?= =?UTF-8?q?=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 ( @@ -49,7 +50,10 @@ function CommandDialog({ {description} - + {children} From ce77268c82a7d2b1ed4618e80e752d3856612718 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:47:36 +0200 Subject: [PATCH 05/53] =?UTF-8?q?=F0=9F=9A=A7=20wip:=20block=20top=20posit?= =?UTF-8?q?ion=20of=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 9 +++++++-- src/components/ui/command.tsx | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index bbf5b9187..3045e534e 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -88,13 +88,18 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { return ( {title} {description} - + {children} From 3bc5ab2136f030c0bc5477ba95001c9b69b7b546 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Sat, 13 Jun 2026 00:48:32 +0200 Subject: [PATCH 06/53] =?UTF-8?q?=F0=9F=9A=A7=20=20wip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/command-palette/CommandPalette.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index 3045e534e..f0c022430 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -43,6 +43,7 @@ type CommandPaletteProps = { * 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) { From c0a9db92ef13556a6fe158dbae0cfb86e9ac341a Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 16 Jun 2026 19:28:22 +0200 Subject: [PATCH 07/53] =?UTF-8?q?=F0=9F=92=84=20update=20command=20bar=20U?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command-palette/CommandPalette.tsx | 110 +++++++++++------- src/components/ui/command.tsx | 13 ++- 2 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index f0c022430..726d1c999 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -1,5 +1,9 @@ "use client"; +import type { + CommandBarNavSection, + SidebarNavSection +} from "@app/app/navigation"; import { CommandDialog, CommandEmpty, @@ -9,13 +13,9 @@ import { CommandList, CommandSeparator } from "@app/components/ui/command"; -import type { - CommandBarNavSection, - SidebarNavSection -} from "@app/app/navigation"; -import { Badge } from "@app/components/ui/badge"; +import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import React, { createContext, @@ -25,13 +25,10 @@ import React, { 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"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { cn } from "@app/lib/cn"; +import { search } from "jmespath"; type CommandPaletteProps = { orgId?: string; @@ -52,14 +49,16 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { 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, resources, users, machineClients, isLoading } = useCommandPaletteSearch({ orgId, - query: search, - enabled: open + query: search.substring(1), + enabled: open && isActionMode }); const handleOpenChange = useCallback( @@ -96,9 +95,24 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { className="max-w-2xl **:data-[slot=command-input-wrapper]:h-15" commandProps={{ loop: true, - filter(value, search) { - if (value.toLowerCase().includes(search.toLowerCase())) + filter(value, query) { + let search = query; + if (query.startsWith(">")) { + search = query.substring(1); + + console.log({ + search, + value + }); + } + + if ( + value + .toLowerCase() + .includes(search.trim().toLowerCase()) + ) { return 1; + } return 0; } }} @@ -107,8 +121,9 @@ export function CommandPalette({ orgId, orgs, navItems }: CommandPaletteProps) { placeholder={t("commandPaletteSearchPlaceholder")} value={search} onValueChange={setSearch} + // isLoading /> - + {t("commandPaletteNoResults")} - {navigationGroups.map((group, groupIndex) => ( - - {groupIndex > 0 && } - 0 && - "[&_[cmdk-group-heading]]:pt-3" - )} - > - {group.items.map((item, itemIndex) => ( - - runCommand(() => router.push(item.href)) - } - className="h-9" + {!isActionMode && + navigationGroups.map((group, groupIndex) => { + console.log({ + groupIndex, + groupHeading: group.heading + }); + return ( + + {groupIndex > 0 && } + 0 && + "[&_[cmdk-group-heading]]:pt-3" + )} > - {item.icon} - {item.title} - - ))} - - - ))} + {group.items.map((item) => ( + + runCommand(() => + router.push(item.href) + ) + } + className="h-9" + > + {item.icon} + {item.title} + + ))} + + + ); + })} {/* {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 08/53] =?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 09/53] =?UTF-8?q?=E2=9C=A8=20Command=20palette=20with=20se?= =?UTF-8?q?arch=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 10/53] =?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 11/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20add=20command=20pale?= =?UTF-8?q?tte=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 12/53] =?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 13/53] =?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" ? ( + + + + { + onSelectedSitesChange([site]); + field.onChange([site.siteId]); + }} + /> + + + ) : ( + + + + + + + + { + onSelectedSitesChange(sites); + field.onChange( + sites.map((s) => s.siteId) + ); + }} + /> + + + )} + + {!singleSite && selectedSites.length > 1 ? ( + + ) : null} + + )} + /> + ); +} diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx b/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx new file mode 100644 index 000000000..e61ed2292 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/PrivateResourceSshFields.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { + SettingsFormCell, + SettingsFormGrid, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle +} from "@app/components/Settings"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; +import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields"; +import { PrivateResourceSitesField } from "./PrivateResourceSitesField"; +import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils"; +import { inferSshPamMode } from "@app/lib/privateResourceForm"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useTranslations } from "next-intl"; +import { useState, type ReactNode } from "react"; +import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form"; +import type { Selectedsite } from "@app/components/site-selector"; + +type PrivateResourceSshFieldsProps = { + control: Control; + setValue: UseFormSetValue; + watch: UseFormWatch; + orgId?: string; + disabled?: boolean; + selectedSites: Selectedsite[]; + onSelectedSitesChange: (sites: Selectedsite[]) => void; + labelPrefix?: "create" | "edit"; + showSshSettings?: boolean; + layout?: "default" | "wizard"; + showPaidFeaturesAlert?: boolean; + hideAlias?: boolean; + embedInParentGrid?: boolean; + isNativeSsh?: boolean; +}; + +export function PrivateResourceSshFields({ + control, + setValue, + watch, + orgId, + disabled = false, + selectedSites, + onSelectedSitesChange, + labelPrefix = "edit", + showSshSettings = true, + layout = "default", + showPaidFeaturesAlert = true, + hideAlias = false, + embedInParentGrid = false, + isNativeSsh: isNativeSshProp +}: PrivateResourceSshFieldsProps) { + const t = useTranslations(); + const destinationLabelKey = + labelPrefix === "create" + ? "createInternalResourceDialogDestination" + : "editInternalResourceDialogDestination"; + const destinationPortLabelKey = + labelPrefix === "create" + ? "createInternalResourceDialogModePort" + : "editInternalResourceDialogModePort"; + + const authDaemonMode = watch("authDaemonMode") ?? "site"; + const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode")); + const standardDaemonLocation = + watch("standardDaemonLocation") ?? + (authDaemonMode === "remote" ? "remote" : "site"); + const formAuthDaemonPort = watch("authDaemonPort"); + const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() => + formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123" + ); + const isEditLayout = layout === "default"; + + const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( + () => (authDaemonMode === "native" ? "native" : "standard") + ); + + const isNative = + isNativeSshProp ?? + (isEditLayout + ? authDaemonMode === "native" + : sshServerMode === "native"); + const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm( + isNative, + authDaemonMode, + pamMode + ); + + function trimSitesToFirst() { + if (selectedSites.length <= 1) return; + + const first = selectedSites.slice(0, 1); + onSelectedSitesChange(first); + setValue( + "siteIds", + first.map((s: Selectedsite) => s.siteId), + { shouldValidate: true } + ); + } + + function handlePamModeChange(value: "passthrough" | "push") { + if (disabled) return; + + setValue("pamMode", value, { shouldValidate: true }); + + if (value === "passthrough") { + setValue("authDaemonPort", null, { shouldValidate: true }); + setAuthDaemonPortInput("22123"); + return; + } + + if (standardDaemonLocation !== "remote" && selectedSites.length > 1) { + trimSitesToFirst(); + } + } + + function handleDaemonLocationChange(value: "site" | "remote") { + if (disabled) return; + + setValue("standardDaemonLocation", value, { shouldValidate: true }); + setValue("authDaemonMode", value, { shouldValidate: true }); + + if (value === "site") { + setValue("authDaemonPort", null, { shouldValidate: true }); + setAuthDaemonPortInput("22123"); + trimSitesToFirst(); + } + } + + function handleAuthDaemonPortChange(value: string) { + if (disabled) return; + + setAuthDaemonPortInput(value); + const trimmed = value.trim(); + setValue("authDaemonPort", trimmed ? Number(trimmed) : null, { + shouldValidate: true + }); + } + + function handleServerModeChange(mode: "standard" | "native") { + if (disabled) return; + + setSshServerMode(mode); + if (mode === "native") { + setValue("authDaemonMode", "native", { shouldValidate: true }); + setValue("authDaemonPort", null, { shouldValidate: true }); + setValue("destination", null, { shouldValidate: true }); + setValue("destinationPort", null, { shouldValidate: true }); + setAuthDaemonPortInput("22123"); + trimSitesToFirst(); + return; + } + + setValue("authDaemonMode", standardDaemonLocation, { + shouldValidate: true + }); + setValue("destinationPort", 22, { shouldValidate: true }); + } + + const aliasField = hideAlias ? null : ( + + ); + + const standardSshTargetRow = + orgId && !isNative ? ( +
+ + ( + + {t(destinationLabelKey)} + + + field.onChange( + e.target.value === "" + ? null + : e.target.value + ) + } + /> + + + + )} + /> + ( + + {t(destinationPortLabelKey)} + + { + const raw = e.target.value; + if (raw === "") { + field.onChange(null); + return; + } + const n = Number(raw); + field.onChange( + Number.isFinite(n) ? n : null + ); + }} + /> + + + + )} + /> +
+ ) : null; + + const sshSettingsFields = showSshSettings ? ( + + ) : null; + + const destinationSection = ( + <> + + + + {t("sshServerDestination")} + + + {t("sshServerDestinationDescription")} + + + + + {isNative && orgId ? ( + <> + + + + + + + + ) : null} + + {!isNative && orgId ? ( + + {standardSshTargetRow} + + ) : null} + + {!isNative && !hideAlias ? ( + {aliasField} + ) : null} + + ); + + const content: ReactNode = ( + <> + {showPaidFeaturesAlert && layout === "default" && ( + + + + )} + {sshSettingsFields} + {destinationSection} + + ); + + if (embedInParentGrid) { + return content; + } + + return {content}; +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx new file mode 100644 index 000000000..e09366329 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/access/page.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + accessTagsToIds, + buildUpdateSiteResourcePayload, + createAccessFormSchema, + mergeFormValuesWithResource +} from "@app/lib/privateResourceForm"; +import { resourceQueries, orgQueries } from "@app/lib/queries"; +import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useActionState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields"; + +export default function PrivateResourceAccessPage() { + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const queryClient = useQueryClient(); + const { siteResource, setAccess } = useSiteResourceContext(); + const { loading, roles, users, clients, hasMachineClients } = + useAccessFormDefaults(siteResource.orgId, siteResource.id); + + const machineClientsQuery = useQuery( + orgQueries.machineClients({ + orgId: siteResource.orgId, + perPage: 1 + }) + ); + const hasMachineClientsResolved = + (machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0; + + const form = useForm({ + resolver: zodResolver(createAccessFormSchema()), + defaultValues: { + roles: [] as typeof roles, + users: [] as typeof users, + clients: [] as typeof clients + } + }); + + useEffect(() => { + if (!loading) { + form.reset({ roles, users, clients }); + } + }, [loading, roles, users, clients, form]); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + const access = accessTagsToIds({ + roles: data.roles, + users: data.users, + clients: data.clients + }); + + const merged = mergeFormValuesWithResource(siteResource, {}); + const payload = buildUpdateSiteResourcePayload(merged, access); + + try { + await api.post(`/site-resource/${siteResource.id}`, payload); + setAccess(access); + + await Promise.all([ + queryClient.invalidateQueries( + resourceQueries.siteResourceRoles({ + siteResourceId: siteResource.id + }) + ), + queryClient.invalidateQueries( + resourceQueries.siteResourceUsers({ + siteResourceId: siteResource.id + }) + ), + queryClient.invalidateQueries( + resourceQueries.siteResourceClients({ + siteResourceId: siteResource.id + }) + ) + ]); + + toast({ + title: t("editInternalResourceDialogSuccess"), + description: t( + "editInternalResourceDialogInternalResourceUpdatedSuccessfully" + ) + }); + } catch (error) { + toast({ + title: t("editInternalResourceDialogError"), + description: formatAxiosError( + error, + t( + "editInternalResourceDialogFailedToUpdateInternalResource" + ) + ), + variant: "destructive" + }); + } + }, null); + + return ( + + + + + {t("authentication")} + + + {t( + "editInternalResourceDialogAccessControlDescription" + )} + + + + + +
+ + + + +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx new file mode 100644 index 000000000..8064ec883 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/cidr/page.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { createCidrFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; +import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; +import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { asAnyControl, asAnySetValue } from "../../formControlUtils"; +import { useSaveSiteResource } from "../../useSaveSiteResource"; + +export default function PrivateResourceCidrPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo(() => createCidrFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "cidr", + destination: siteResource.destination ?? "", + tcpPortRangeString: siteResource.tcpPortRangeString ?? "*", + udpPortRangeString: siteResource.udpPortRangeString ?? "*", + disableIcmp: siteResource.disableIcmp ?? false + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + siteIds: data.siteIds, + mode: "cidr", + destination: data.destination, + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp + }); + }, null); + + return ( + + + + + {t("cidrSettings")} + + + {t( + "editInternalResourceDialogDestinationCidrDescription" + )} + + + + + +
+ + + + + + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx new file mode 100644 index 000000000..80cdc41ea --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { createGeneralFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { useSaveSiteResource } from "../../useSaveSiteResource"; + +export default function PrivateResourceGeneralPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + + const formSchema = useMemo(() => createGeneralFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: siteResource.name, + niceId: siteResource.niceId + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + name: data.name, + niceId: data.niceId + }); + }, null); + + return ( + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + + + + +
+ + + + ( + + + {t( + "editInternalResourceDialogName" + )} + + + + + + + )} + /> + + + ( + + + {t("identifier")} + + + + + + + )} + /> + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx new file mode 100644 index 000000000..73d4207cd --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/host/page.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { createHostFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; +import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields"; +import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges"; +import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "../../formControlUtils"; +import { useSaveSiteResource } from "../../useSaveSiteResource"; + +export default function PrivateResourceHostPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo(() => createHostFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "host", + destination: siteResource.destination ?? "", + alias: siteResource.alias ?? null, + tcpPortRangeString: siteResource.tcpPortRangeString ?? "*", + udpPortRangeString: siteResource.udpPortRangeString ?? "*", + disableIcmp: siteResource.disableIcmp ?? false, + authDaemonMode: siteResource.authDaemonMode ?? "site", + authDaemonPort: siteResource.authDaemonPort ?? null + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + siteIds: data.siteIds, + mode: "host", + destination: data.destination, + alias: data.alias, + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp, + authDaemonMode: data.authDaemonMode, + authDaemonPort: data.authDaemonPort + }); + }, null); + + return ( + + + + + {t("hostSettings")} + + + {t("editInternalResourceDialogDestinationDescription")} + + + + + +
+ + + + + + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx new file mode 100644 index 000000000..d69210ef5 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { + SettingsContainer, + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { createHttpFormSchema } from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "../../PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields"; +import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "../../formControlUtils"; +import { useSaveSiteResource } from "../../useSaveSiteResource"; + +export default function PrivateResourceHttpPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const { isPaidUser } = usePaidStatus(); + const httpSectionDisabled = !isPaidUser( + tierMatrix.advancedPrivateResources + ); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo(() => createHttpFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "http", + destination: siteResource.destination ?? "", + destinationPort: siteResource.destinationPort ?? null, + scheme: siteResource.scheme ?? "http", + ssl: siteResource.ssl ?? false, + httpConfigSubdomain: siteResource.subdomain ?? null, + httpConfigDomainId: siteResource.domainId ?? null, + httpConfigFullDomain: siteResource.fullDomain ?? null + } + }); + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + await save({ + siteIds: data.siteIds, + mode: "http", + destination: data.destination, + destinationPort: data.destinationPort, + scheme: data.scheme, + ssl: data.ssl, + httpConfigSubdomain: data.httpConfigSubdomain, + httpConfigDomainId: data.httpConfigDomainId, + httpConfigFullDomain: data.httpConfigFullDomain + }); + }, null); + + return ( + + + + + {t("httpSettings")} + + + {t( + "editInternalResourceDialogHttpConfigurationDescription" + )} + + + + + +
+ + + + + + + + + + +
+ +
+
+ + + + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx new file mode 100644 index 000000000..a1e97e188 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/layout.tsx @@ -0,0 +1,93 @@ +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import OrgProvider from "@app/providers/OrgProvider"; +import SiteResourceProvider from "@app/providers/SiteResourceProvider"; +import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Private Resource" +}; + +export const dynamic = "force-dynamic"; + +type PrivateResourceLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: string; orgId: string }>; +}; + +export default async function PrivateResourceLayout( + props: PrivateResourceLayoutProps +) { + const params = await props.params; + const t = await getTranslations(); + const { children } = props; + + const siteResource = await fetchSiteResourceByNiceId( + params.orgId, + params.niceId + ); + + if (!siteResource) { + redirect(`/${params.orgId}/settings/resources/private`); + } + + let org = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources/private`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources/private`); + } + + const modeSettingsKey = `${siteResource.mode}Settings` as + | "hostSettings" + | "cidrSettings" + | "httpSettings" + | "sshSettings"; + + const navItems = [ + { + title: t("general"), + href: `/{orgId}/settings/resources/private/{niceId}/general` + }, + { + title: t(modeSettingsKey), + href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}` + }, + { + title: t("authentication"), + href: `/{orgId}/settings/resources/private/{niceId}/access` + } + ]; + + return ( + <> + + + + +
+ + + {children} + +
+
+
+ + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx new file mode 100644 index 000000000..9a7b7e5b3 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +export const metadata: Metadata = { + title: "Private Resource" +}; + +export default async function PrivateResourcePage(props: { + params: Promise<{ niceId: string; orgId: string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/resources/private/${params.niceId}/general` + ); +} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx new file mode 100644 index 000000000..6de4082b6 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/[niceId]/ssh/page.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsFormGrid +} from "@app/components/Settings"; +import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { Button } from "@app/components/ui/button"; +import { Form } from "@app/components/ui/form"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { + createSshFormSchema, + inferSshPamMode +} from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { useTranslations } from "next-intl"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSshFields } from "../../PrivateResourceSshFields"; +import { buildSelectedSitesForResource } from "../../privateResourceUtils"; +import { + asAnyControl, + asAnySetValue, + asAnyWatch +} from "../../formControlUtils"; +import { useSaveSiteResource } from "../../useSaveSiteResource"; +import type { Selectedsite } from "@app/components/site-selector"; + +export default function PrivateResourceSshPage() { + const t = useTranslations(); + const { save, siteResource } = useSaveSiteResource(); + const { isPaidUser } = usePaidStatus(); + const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); + const isNative = siteResource.authDaemonMode === "native"; + const [sshServerMode] = useState<"standard" | "native">( + isNative ? "native" : "standard" + ); + const [selectedSites, setSelectedSites] = useState(() => + buildSelectedSitesForResource(siteResource) + ); + + const formSchema = useMemo( + () => createSshFormSchema(t, { isNative }), + [t, isNative] + ); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + siteIds: siteResource.siteIds, + mode: "ssh", + destination: siteResource.destination ?? "", + alias: siteResource.alias ?? null, + destinationPort: siteResource.destinationPort ?? null, + pamMode: inferSshPamMode( + siteResource.authDaemonMode, + siteResource.pamMode + ), + standardDaemonLocation: isNative + ? "site" + : siteResource.authDaemonMode === "remote" + ? "remote" + : "site", + authDaemonPort: siteResource.authDaemonPort + ? String(siteResource.authDaemonPort) + : "22123" + } + }); + + const pamMode = form.watch("pamMode"); + const standardDaemonLocation = form.watch("standardDaemonLocation"); + const authDaemonPort = form.watch("authDaemonPort"); + + function trimSitesToFirst() { + if (selectedSites.length <= 1) return; + + const first = selectedSites.slice(0, 1); + setSelectedSites(first); + form.setValue( + "siteIds", + first.map((s: Selectedsite) => s.siteId), + { shouldValidate: true } + ); + } + + function handlePamModeChange(value: "passthrough" | "push") { + form.setValue("pamMode", value, { shouldValidate: true }); + + if (value === "push") { + if ( + standardDaemonLocation !== "remote" && + selectedSites.length > 1 + ) { + trimSitesToFirst(); + } + return; + } + + form.setValue("authDaemonPort", "22123", { shouldValidate: true }); + } + + function handleDaemonLocationChange(value: "site" | "remote") { + form.setValue("standardDaemonLocation", value, { + shouldValidate: true + }); + + if (value === "site") { + form.setValue("authDaemonPort", "22123", { shouldValidate: true }); + trimSitesToFirst(); + } + } + + const [, formAction, saveLoading] = useActionState(async () => { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + const effectiveAuthDaemonMode = isNative + ? "native" + : data.standardDaemonLocation; + const effectiveAuthDaemonPort = + !isNative && + data.pamMode === "push" && + data.standardDaemonLocation === "remote" + ? Number(data.authDaemonPort) + : null; + + await save({ + siteIds: data.siteIds, + mode: "ssh", + destination: isNative ? null : data.destination, + alias: data.alias, + destinationPort: isNative ? null : data.destinationPort, + authDaemonMode: effectiveAuthDaemonMode, + authDaemonPort: effectiveAuthDaemonPort, + pamMode: data.pamMode + }); + }, null); + + return ( + + + + + + {t("sshSettings")} + + + {t("editInternalResourceDialogDestinationDescription")} + + + +
+
+ + + + + form.setValue( + "authDaemonPort", + value, + { shouldValidate: true } + ) + } + authDaemonPortError={ + form.formState.errors.authDaemonPort + ?.message + } + sshServerMode={sshServerMode} + serverModeDisplay="badge" + /> + + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx new file mode 100644 index 000000000..f8109fd66 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -0,0 +1,615 @@ +"use client"; + +import { + SettingsFormCell, + SettingsFormGrid, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { + OptionSelect, + type OptionSelectOption +} from "@app/components/OptionSelect"; +import DomainPicker from "@app/components/DomainPicker"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import type { Selectedsite } from "@app/components/site-selector"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + buildCreateSiteResourcePayload, + createCreateFormSchema, + type PrivateResourceMode +} from "@app/lib/privateResourceForm"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import type { SiteResource } from "@server/db"; +import { GetSiteResponse } from "@server/routers/site/getSite"; +import type ResponseT from "@server/types/Response"; +import { AxiosResponse } from "axios"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; +import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; +import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; +import { + PrivateResourceAliasField, + PrivateResourceCidrDestinationField, + PrivateResourceHostDestinationFields +} from "../PrivateResourceDestinationFields"; +import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils"; + +export default function CreatePrivateResourcePage() { + const params = useParams(); + const searchParams = useSearchParams(); + const router = useRouter(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const orgId = params.orgId as string; + const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; + const { isPaidUser } = usePaidStatus(); + const httpSectionDisabled = !isPaidUser( + tierMatrix.advancedPrivateResources + ); + const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); + const [isSubmitting, startTransition] = useTransition(); + + const siteIdParam = searchParams.get("siteId"); + const siteIdNumber = + siteIdParam && Number.isInteger(Number(siteIdParam)) + ? Number(siteIdParam) + : null; + + const [selectedSites, setSelectedSites] = useState([]); + + const formSchema = useMemo(() => createCreateFormSchema(t), [t]); + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: "", + siteIds: [], + mode: "host", + destination: "", + alias: null, + destinationPort: null, + scheme: "http", + ssl: true, + httpConfigSubdomain: null, + httpConfigDomainId: null, + httpConfigFullDomain: null, + authDaemonMode: "native", + standardDaemonLocation: "site", + authDaemonPort: null, + pamMode: "passthrough", + disableIcmp: false + } + }); + + useEffect(() => { + if (!siteIdNumber) return; + + void api + .get>(`/site/${siteIdNumber}`) + .then((res) => { + const site = res.data.data; + if (!site || site.orgId !== orgId) return; + const selected: Selectedsite = { + siteId: site.siteId, + name: site.name, + type: site.type as Selectedsite["type"] + }; + setSelectedSites([selected]); + form.setValue("siteIds", [site.siteId]); + }) + .catch(() => {}); + }, [api, form, orgId, siteIdNumber]); + + const mode = form.watch("mode"); + const authDaemonMode = form.watch("authDaemonMode"); + const isNativeSsh = mode === "ssh" && authDaemonMode === "native"; + + const modeOptions: OptionSelectOption[] = [ + { value: "host", label: t("createInternalResourceDialogModeHost") }, + { value: "cidr", label: t("createInternalResourceDialogModeCidr") }, + ...(!disableEnterpriseFeatures + ? [ + { + value: "http" as const, + label: t("createInternalResourceDialogModeHttp") + }, + { + value: "ssh" as const, + label: t("createInternalResourceDialogModeSsh") + } + ] + : []) + ]; + + const submitDisabled = + isSubmitting || + (mode === "http" && httpSectionDisabled) || + (mode === "ssh" && sshSectionDisabled); + + function onSubmit(values: FormValues) { + startTransition(async () => { + try { + const res = await api.put< + AxiosResponse> + >( + `/org/${orgId}/site-resource`, + buildCreateSiteResourcePayload({ + ...values, + destination: + values.destination?.trim() && + values.destination.trim().length > 0 + ? values.destination.trim() + : null + }) + ); + + toast({ + title: t("createInternalResourceDialogSuccess"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ) + }); + + const created = (res.data as unknown as ResponseT) + .data; + if (!created) { + throw new Error("Failed to create private resource"); + } + + router.push( + `/${orgId}/settings/resources/private/${created.niceId}/${created.mode}` + ); + } catch (error) { + toast({ + title: t("createInternalResourceDialogError"), + description: formatAxiosError( + error, + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) + ), + variant: "destructive" + }); + } + }); + } + + return ( + <> +
+ + +
+ +
+ + {/* General */} + + + + {t("resourceCreateGeneral")} + + + {t("resourceCreateGeneralDescription")} + + + + + + + ( + + + {t("name")} + + + + + + + {t( + "resourceNameDescription" + )} + + + )} + /> + + + + ( + + + {t("type")} + + + options={modeOptions} + value={field.value} + onChange={(newMode) => { + field.onChange( + newMode + ); + if ( + newMode === + "ssh" + ) { + form.setValue( + "authDaemonMode", + "native" + ); + form.setValue( + "standardDaemonLocation", + "site" + ); + form.setValue( + "destination", + null + ); + form.setValue( + "destinationPort", + null + ); + } else if ( + newMode === + "http" + ) { + form.setValue( + "destinationPort", + 443 + ); + } else { + form.setValue( + "destinationPort", + null + ); + } + }} + cols={4} + /> + + + )} + /> + + + {mode === "http" && ( + + + { + if (!res) { + form.setValue( + "httpConfigSubdomain", + null + ); + form.setValue( + "httpConfigDomainId", + null + ); + form.setValue( + "httpConfigFullDomain", + null + ); + return; + } + form.setValue( + "httpConfigSubdomain", + res.subdomain ?? + null + ); + form.setValue( + "httpConfigDomainId", + res.domainId + ); + form.setValue( + "httpConfigFullDomain", + res.fullDomain + ); + }} + /> + + + {t( + "resourceDomainDescription" + )} + + + + )} + + {(mode === "host" || + (mode === "ssh" && !isNativeSsh)) && ( + + + + )} + + + + + + {/* Host destination */} + {mode === "host" && ( + + + + {t("hostSettings")} + + + {t( + "editInternalResourceDialogDestinationDescription" + )} + + + + + + + + + + + + + + + + )} + + {/* CIDR destination */} + {mode === "cidr" && ( + + + + {t("cidrSettings")} + + + {t( + "editInternalResourceDialogDestinationCidrDescription" + )} + + + + + + + + + + + + + + + + )} + + {/* HTTP configuration */} + {mode === "http" && ( + + + + + {t("httpSettings")} + + + {t( + "editInternalResourceDialogHttpConfigurationDescription" + )} + + +
+ + + + + + + + + + + + +
+
+ )} + + {/* SSH server */} + {mode === "ssh" && ( + + + + + {t("sshServer")} + + + {t("sshServerDescription")} + + +
+ + + + + +
+
+ )} + +
+ + +
+
+ + + ); +} diff --git a/src/app/[orgId]/settings/resources/private/formControlUtils.ts b/src/app/[orgId]/settings/resources/private/formControlUtils.ts new file mode 100644 index 000000000..748a1fab0 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/formControlUtils.ts @@ -0,0 +1,24 @@ +import type { + Control, + FieldValues, + UseFormSetValue, + UseFormWatch +} from "react-hook-form"; + +export function asAnyControl( + control: Control +): Control { + return control as Control; +} + +export function asAnySetValue( + setValue: UseFormSetValue +): UseFormSetValue { + return setValue as UseFormSetValue; +} + +export function asAnyWatch( + watch: UseFormWatch +): UseFormWatch { + return watch as UseFormWatch; +} diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index 23ff86296..10ef118f2 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -122,6 +122,7 @@ export default async function ClientResourcesPage( aliasAddress: siteResource.aliasAddress || null, siteNiceIds: siteResource.siteNiceIds, niceId: siteResource.niceId, + enabled: siteResource.enabled, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, diff --git a/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts b/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts new file mode 100644 index 000000000..37cc63756 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/privateResourceUtils.ts @@ -0,0 +1,36 @@ +"use client"; + +import type { Selectedsite } from "@app/components/site-selector"; +import type { SiteResourceData } from "@app/lib/privateResourceForm"; + +export function buildSelectedSitesForResource( + resource: Pick +): Selectedsite[] { + return resource.siteIds.map((siteId, idx) => ({ + name: resource.siteNames[idx] ?? "", + siteId, + type: "newt" as const + })); +} + +export function getSshSingleSiteMode( + authDaemonMode?: string | null, + pamMode?: string | null +): boolean { + return ( + authDaemonMode === "native" || + (pamMode === "push" && authDaemonMode === "site") + ); +} + +export function getSshUseMultiSiteTargetForm( + isNative: boolean, + authDaemonMode?: string | null, + pamMode?: string | null +): boolean { + if (isNative) { + return false; + } + + return authDaemonMode !== "site" || pamMode === "passthrough"; +} diff --git a/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts b/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts new file mode 100644 index 000000000..0515c4673 --- /dev/null +++ b/src/app/[orgId]/settings/resources/private/useSaveSiteResource.ts @@ -0,0 +1,106 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; +import { + buildUpdateSiteResourcePayload, + mergeFormValuesWithResource, + type PrivateResourceFormValues +} from "@app/lib/privateResourceForm"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; + +export function useSaveSiteResource() { + const t = useTranslations(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { siteResource, updateSiteResource, access } = + useSiteResourceContext(); + + async function save( + partial: Partial, + options?: { successMessage?: string } + ) { + const merged = mergeFormValuesWithResource(siteResource, partial); + const isNativeSsh = + merged.mode === "ssh" && merged.authDaemonMode === "native"; + const trimmedDestination = merged.destination?.trim(); + + const payload = buildUpdateSiteResourcePayload( + { + ...merged, + destination: isNativeSsh + ? null + : trimmedDestination && trimmedDestination.length > 0 + ? trimmedDestination + : null + }, + access + ); + + try { + await api.post(`/site-resource/${siteResource.id}`, payload); + + updateSiteResource({ + name: merged.name, + niceId: merged.niceId ?? siteResource.niceId, + enabled: merged.enabled ?? siteResource.enabled, + siteIds: merged.siteIds, + mode: merged.mode, + destination: merged.destination ?? null, + alias: merged.alias ?? null, + destinationPort: merged.destinationPort ?? null, + scheme: merged.scheme ?? siteResource.scheme, + ssl: merged.ssl ?? siteResource.ssl, + subdomain: merged.httpConfigSubdomain ?? null, + domainId: merged.httpConfigDomainId ?? null, + fullDomain: merged.httpConfigFullDomain ?? null, + tcpPortRangeString: merged.tcpPortRangeString ?? null, + udpPortRangeString: merged.udpPortRangeString ?? null, + disableIcmp: merged.disableIcmp ?? false, + authDaemonMode: merged.authDaemonMode ?? null, + authDaemonPort: merged.authDaemonPort ?? null, + pamMode: merged.pamMode ?? null + }); + + toast({ + title: t("editInternalResourceDialogSuccess"), + description: + options?.successMessage ?? + t( + "editInternalResourceDialogInternalResourceUpdatedSuccessfully" + ) + }); + + if (merged.niceId && merged.niceId !== siteResource.niceId) { + router.replace( + getPrivateResourceSettingsHref( + siteResource.orgId, + merged.niceId + ) + ); + } + + router.refresh(); + return true; + } catch (error) { + toast({ + title: t("editInternalResourceDialogError"), + description: formatAxiosError( + error, + t( + "editInternalResourceDialogFailedToUpdateInternalResource" + ) + ), + variant: "destructive" + }); + return false; + } + } + + return { save, siteResource, access }; +} diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx index 8696e1967..7054740cf 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/ssh/page.tsx @@ -14,14 +14,13 @@ import { SettingsSubsectionHeader, SettingsSubsectionTitle } from "@app/components/Settings"; -import { StrategySelect, StrategyOption } from "@app/components/StrategySelect"; +import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields"; import { BrowserGatewayTargetForm } from "@app/components/BrowserGatewayTargetForm"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SitesSelector } from "@app/components/site-selector"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { Button } from "@app/components/ui/button"; -import { Input } from "@app/components/ui/input"; import { Form, FormControl, @@ -35,8 +34,7 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { ChevronsUpDown, ExternalLink } from "lucide-react"; -import { Badge } from "@app/components/ui/badge"; +import { ChevronsUpDown } from "lucide-react"; import { toast } from "@app/hooks/useToast"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -223,6 +221,7 @@ function SshServerForm({ const pamMode = form.watch("pamMode"); const standardDaemonLocation = form.watch("standardDaemonLocation"); + const authDaemonPort = form.watch("authDaemonPort"); const selectedNativeSite = form.watch("selectedNativeSite"); async function save() { @@ -364,35 +363,6 @@ function SshServerForm({ } } - const authMethodOptions: StrategyOption<"passthrough" | "push">[] = [ - { - id: "passthrough", - title: t("sshAuthMethodManual"), - description: t("sshAuthMethodManualDescription") - }, - { - id: "push", - title: t("sshAuthMethodAutomated"), - description: t("sshAuthMethodAutomatedDescription") - } - ]; - - const daemonLocationOptions: StrategyOption<"site" | "remote">[] = [ - { - id: "site", - title: t("internalResourceAuthDaemonSite"), - description: t("sshDaemonLocationSiteDescription") - }, - { - id: "remote", - title: t("sshDaemonLocationRemote"), - description: t("sshDaemonLocationRemoteDescription") - } - ]; - - const showDaemonLocation = !isNative && pamMode === "push"; - const showDaemonPort = - !isNative && pamMode === "push" && standardDaemonLocation === "remote"; const useMultiSiteTargetForm = !isNative && (standardDaemonLocation !== "site" || pamMode === "passthrough"); @@ -413,97 +383,37 @@ function SshServerForm({ - -
-

- {t("sshServerMode")} -

- - {sshServerMode == "standard" - ? t("sshServerModeStandard") - : t("sshServerModePangolin")} - -
-
- - -
-

- {t("sshAuthenticationMethod")} -

- - value={pamMode} - options={authMethodOptions} - onChange={(value) => - form.setValue("pamMode", value, { - shouldValidate: true - }) - } - cols={2} - /> -
-
- - {showDaemonLocation && ( - -
-

- {t("sshAuthDaemonLocation")} -

- - value={standardDaemonLocation} - options={daemonLocationOptions} - onChange={(value) => - form.setValue( - "standardDaemonLocation", - value, - { - shouldValidate: true - } - ) - } - cols={2} - /> -

- {t("sshDaemonDisclaimer")}{" "} - - {t("learnMore")} - - -

-
-
- )} - - {showDaemonPort && ( - - ( - - - {t("sshDaemonPort")} - - - - - - - )} - /> - - )} + + form.setValue("pamMode", value, { + shouldValidate: true + }) + } + onStandardDaemonLocationChange={(value) => + form.setValue( + "standardDaemonLocation", + value, + { shouldValidate: true } + ) + } + onAuthDaemonPortChange={(value) => + form.setValue("authDaemonPort", value, { + shouldValidate: true + }) + } + authDaemonPortError={ + form.formState.errors.authDaemonPort + ?.message + } + sshServerMode={sshServerMode} + serverModeDisplay="badge" + /> diff --git a/src/app/[orgId]/settings/resources/public/create/page.tsx b/src/app/[orgId]/settings/resources/public/create/page.tsx index be02b8e07..43d9528b9 100644 --- a/src/app/[orgId]/settings/resources/public/create/page.tsx +++ b/src/app/[orgId]/settings/resources/public/create/page.tsx @@ -777,8 +777,8 @@ export default function Page() { <>
- - - - - - ); -} diff --git a/src/components/EditPrivateResourceDialog.tsx b/src/components/EditPrivateResourceDialog.tsx deleted file mode 100644 index 077c85ee0..000000000 --- a/src/components/EditPrivateResourceDialog.tsx +++ /dev/null @@ -1,225 +0,0 @@ -"use client"; - -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { Button } from "@app/components/ui/button"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { resourceQueries } from "@app/lib/queries"; -import { useQueryClient } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { useState, useTransition } from "react"; -import { - cleanForFQDN, - PrivateResourceForm, - type InternalResourceData, - type InternalResourceFormValues, - isHostname -} from "./PrivateResourceForm"; - -type EditInternalResourceDialogProps = { - open: boolean; - setOpen: (val: boolean) => void; - resource: InternalResourceData; - orgId: string; - onSuccess?: () => void; -}; - -export default function EditPrivateResourceDialog({ - open, - setOpen, - resource, - orgId, - onSuccess -}: EditInternalResourceDialogProps) { - const t = useTranslations(); - const api = createApiClient(useEnvContext()); - const queryClient = useQueryClient(); - const [isSubmitting, startTransition] = useTransition(); - const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false); - - async function handleSubmit(values: InternalResourceFormValues) { - try { - let data = { ...values }; - if ( - (data.mode === "host" || - data.mode === "http" || - data.mode === "ssh") && - isHostname(data.destination) - ) { - const currentAlias = data.alias?.trim() || ""; - if (!currentAlias) { - let aliasValue = data.destination; - if (data.destination?.toLowerCase() === "localhost") { - aliasValue = `${cleanForFQDN(data.name)}.internal`; - } - data = { ...data, alias: aliasValue }; - } - } - - await api.post(`/site-resource/${resource.id}`, { - name: data.name, - siteIds: data.siteIds, - mode: data.mode, - niceId: data.niceId, - destination: data.destination ?? undefined, - ...(data.mode === "http" && { - scheme: data.scheme, - ssl: data.ssl ?? false, - destinationPort: data.destinationPort ?? null, - domainId: data.httpConfigDomainId - ? data.httpConfigDomainId - : undefined, - subdomain: data.httpConfigSubdomain - ? data.httpConfigSubdomain - : undefined - }), - ...(data.mode === "host" && { - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode - }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null - }) - }), - ...(data.mode === "ssh" && { - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - destinationPort: data.destinationPort ?? null, - pamMode: data.pamMode ?? undefined, - ...(data.authDaemonMode != null && { - authDaemonMode: data.authDaemonMode - }), - ...(data.authDaemonMode === "remote" && { - authDaemonPort: data.authDaemonPort || null - }) - }), - ...((data.mode === "host" || data.mode === "cidr") && { - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false - }), - ...(data.mode === "ssh" && { - disableIcmp: data.disableIcmp ?? false - }), - roleIds: (data.roles || []).map((r) => parseInt(r.id)), - userIds: (data.users || []).map((u) => u.id), - clientIds: (data.clients || []).map((c) => parseInt(c.id)) - }); - - await queryClient.invalidateQueries( - resourceQueries.siteResourceRoles({ - siteResourceId: resource.id - }) - ); - await queryClient.invalidateQueries( - resourceQueries.siteResourceUsers({ - siteResourceId: resource.id - }) - ); - await queryClient.invalidateQueries( - resourceQueries.siteResourceClients({ - siteResourceId: resource.id - }) - ); - - toast({ - title: t("editInternalResourceDialogSuccess"), - description: t( - "editInternalResourceDialogInternalResourceUpdatedSuccessfully" - ), - variant: "default" - }); - setOpen(false); - onSuccess?.(); - } catch (error) { - toast({ - title: t("editInternalResourceDialogError"), - description: formatAxiosError( - error, - t( - "editInternalResourceDialogFailedToUpdateInternalResource" - ) - ), - variant: "destructive" - }); - } - } - - return ( - { - if (!isOpen) setOpen(false); - }} - > - - - - {t("editInternalResourceDialogEditClientResource")} - - - {t( - "editInternalResourceDialogUpdateResourceProperties", - { - resourceName: resource.name - } - )} - - - - - startTransition(() => handleSubmit(values)) - } - onSubmitDisabledChange={setIsHttpModeDisabled} - /> - - - - - - - - - - ); -} diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index c26303112..989311c0b 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -12,7 +12,7 @@ export function InfoSections({ cols?: number; /** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */ columnSizing?: "fill" | "content"; - /** panel: 2 columns until xl for narrow containers such as side panels */ + /** panel: fixed 2-column grid for narrow containers such as side panels */ layout?: "default" | "panel"; }) { const n = cols || 1; @@ -24,11 +24,11 @@ export function InfoSections({ className={cn( "grid w-full min-w-0 gap-4", layout === "panel" - ? "grid-cols-2 xl:grid-cols-(--columns) xl:items-start xl:space-x-16" + ? "grid-cols-2 items-start" : "grid-cols-2 md:grid-cols-(--columns) md:items-start md:space-x-16", columnSizing === "content" && (layout === "panel" - ? "xl:justify-items-start xl:justify-start" + ? "justify-items-start justify-start" : "md:justify-items-start md:justify-start") )} style={{ diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx deleted file mode 100644 index 23fd8a7b2..000000000 --- a/src/components/PrivateResourceForm.tsx +++ /dev/null @@ -1,2147 +0,0 @@ -"use client"; - -import { - SettingsSubsectionDescription, - SettingsSubsectionHeader, - SettingsSubsectionTitle -} from "@app/components/Settings"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { - OptionSelect, - type OptionSelectOption -} from "@app/components/OptionSelect"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { StrategySelect } from "@app/components/StrategySelect"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Button } from "@app/components/ui/button"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { Switch } from "@app/components/ui/switch"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { cn } from "@app/lib/cn"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { UserType } from "@server/types/UserTypes"; -import { useQuery } from "@tanstack/react-query"; -import { - ArrowDownIcon, - ChevronDownIcon, - ChevronsUpDown, - ExternalLink -} from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { - MultiSitesSelector, - formatMultiSitesSelectorLabel -} from "./multi-site-selector"; -import { SitesSelector } from "./site-selector"; -import type { Selectedsite } from "./site-selector"; - -import { MachinesSelector } from "./machines-selector"; -import DomainPicker from "@app/components/DomainPicker"; -import { SwitchInput } from "@app/components/SwitchInput"; -import CertificateStatus from "@app/components/CertificateStatus"; -import { UsersSelector } from "./users-selector"; -import { RolesSelector } from "./roles-selector"; -import { build } from "@server/build"; - -// --- Helpers (shared) --- - -const isValidPortRangeString = (val: string | undefined | null): boolean => { - if (!val || val.trim() === "" || val.trim() === "*") return true; - const parts = val.split(",").map((p) => p.trim()); - for (const part of parts) { - if (part === "") return false; - if (part.includes("-")) { - const [start, end] = part.split("-").map((p) => p.trim()); - if (!start || !end) return false; - const startPort = parseInt(start, 10); - const endPort = parseInt(end, 10); - if (isNaN(startPort) || isNaN(endPort)) return false; - if ( - startPort < 1 || - startPort > 65535 || - endPort < 1 || - endPort > 65535 - ) - return false; - if (startPort > endPort) return false; - } else { - const port = parseInt(part, 10); - if (isNaN(port) || port < 1 || port > 65535) return false; - } - } - return true; -}; - -const getPortRangeValidationMessage = (t: (key: string) => string) => - t("editInternalResourceDialogPortRangeValidationError"); - -const createPortRangeStringSchema = (t: (key: string) => string) => - z - .string() - .optional() - .nullable() - .refine((val) => isValidPortRangeString(val), { - message: getPortRangeValidationMessage(t) - }); - -export type PortMode = "all" | "blocked" | "custom"; -export const getPortModeFromString = ( - val: string | undefined | null -): PortMode => { - if (val === "*") return "all"; - if (!val || val.trim() === "") return "blocked"; - return "custom"; -}; - -export const getPortStringFromMode = ( - mode: PortMode, - customValue: string -): string | undefined => { - if (mode === "all") return "*"; - if (mode === "blocked") return ""; - return customValue; -}; - -export const isHostname = (destination: string | null): boolean => - !!destination && /[a-zA-Z]/.test(destination); - -export const cleanForFQDN = (name: string): string => - name - .toLowerCase() - .replace(/[^a-z0-9.-]/g, "-") - .replace(/[-]+/g, "-") - .replace(/^-|-$/g, "") - .replace(/^\.|\.$/g, ""); - -// --- Types --- - -export type InternalResourceMode = "host" | "cidr" | "http" | "ssh"; - -export type InternalResourceData = { - id: number; - name: string; - orgId: string; - siteNames: string[]; - mode: InternalResourceMode; - siteIds: number[]; - niceId: string; - destination: string | null; - alias?: string | null; - tcpPortRangeString?: string | null; - udpPortRangeString?: string | null; - disableIcmp?: boolean; - authDaemonMode?: "site" | "remote" | "native" | null; - authDaemonPort?: number | null; - pamMode?: "passthrough" | "push" | null; - destinationPort?: number | null; - scheme?: "http" | "https" | null; - ssl?: boolean; - subdomain?: string | null; - domainId?: string | null; - fullDomain?: string | null; -}; - -const tagSchema = z.object({ id: z.string(), text: z.string() }); - -function buildSelectedSitesForResource( - resource: InternalResourceData -): Selectedsite[] { - return resource.siteIds.map((siteId, idx) => ({ - name: resource.siteNames[idx] ?? "", - siteId, - type: "newt" as const - })); -} - -export type InternalResourceFormValues = { - name: string; - siteIds: number[]; - mode: InternalResourceMode; - destination: string | null; - alias?: string | null; - niceId?: string; - tcpPortRangeString?: string | null; - udpPortRangeString?: string | null; - disableIcmp?: boolean; - authDaemonMode?: "site" | "remote" | "native" | null; - authDaemonPort?: number | null; - pamMode?: "passthrough" | "push" | null; - destinationPort?: number | null; - scheme?: "http" | "https"; - ssl?: boolean; - httpConfigSubdomain?: string | null; - httpConfigDomainId?: string | null; - httpConfigFullDomain?: string | null; - roles?: z.infer[]; - users?: z.infer[]; - clients?: z.infer[]; -}; - -type InternalResourceFormProps = { - variant: "create" | "edit"; - resource?: InternalResourceData; - open?: boolean; - orgId: string; - siteResourceId?: number; - formId: string; - onSubmit: (values: InternalResourceFormValues) => void | Promise; - onSubmitDisabledChange?: (disabled: boolean) => void; - initialSites?: Selectedsite[]; -}; - -export function PrivateResourceForm({ - variant, - resource, - open, - orgId, - siteResourceId, - formId, - onSubmit, - onSubmitDisabledChange, - initialSites = [] -}: InternalResourceFormProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - const { isPaidUser } = usePaidStatus(); - const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures; - const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources); - const httpSectionDisabled = !isPaidUser( - tierMatrix.advancedPrivateResources - ); - - const nameRequiredKey = - variant === "create" - ? "createInternalResourceDialogNameRequired" - : "editInternalResourceDialogNameRequired"; - const nameMaxKey = - variant === "create" - ? "createInternalResourceDialogNameMaxLength" - : "editInternalResourceDialogNameMaxLength"; - const siteRequiredKey = - variant === "create" - ? "createInternalResourceDialogPleaseSelectSite" - : undefined; - const nameLabelKey = - variant === "create" - ? "createInternalResourceDialogName" - : "editInternalResourceDialogName"; - const modeLabelKey = - variant === "create" - ? "createInternalResourceDialogMode" - : "editInternalResourceDialogMode"; - const modeHostKey = - variant === "create" - ? "createInternalResourceDialogModeHost" - : "editInternalResourceDialogModeHost"; - const modeCidrKey = - variant === "create" - ? "createInternalResourceDialogModeCidr" - : "editInternalResourceDialogModeCidr"; - const modeHttpKey = - variant === "create" - ? "createInternalResourceDialogModeHttp" - : "editInternalResourceDialogModeHttp"; - const modeSshKey = - variant === "create" - ? "createInternalResourceDialogModeSsh" - : "editInternalResourceDialogModeSsh"; - const schemeLabelKey = - variant === "create" - ? "createInternalResourceDialogScheme" - : "editInternalResourceDialogScheme"; - const enableSslLabelKey = - variant === "create" - ? "createInternalResourceDialogEnableSsl" - : "editInternalResourceDialogEnableSsl"; - const enableSslDescriptionKey = - variant === "create" - ? "createInternalResourceDialogEnableSslDescription" - : "editInternalResourceDialogEnableSslDescription"; - const destinationLabelKey = - variant === "create" - ? "createInternalResourceDialogDestination" - : "editInternalResourceDialogDestination"; - const destinationRequiredKey = - variant === "create" - ? "createInternalResourceDialogDestinationRequired" - : undefined; - const aliasLabelKey = - variant === "create" - ? "createInternalResourceDialogAlias" - : "editInternalResourceDialogAlias"; - const destinationPortLabelKey = - variant === "create" - ? "createInternalResourceDialogModePort" - : "editInternalResourceDialogModePort"; - const httpConfigurationTitleKey = - variant === "create" - ? "createInternalResourceDialogHttpConfiguration" - : "editInternalResourceDialogHttpConfiguration"; - const httpConfigurationDescriptionKey = - variant === "create" - ? "createInternalResourceDialogHttpConfigurationDescription" - : "editInternalResourceDialogHttpConfigurationDescription"; - - const siteIdsSchema = siteRequiredKey - ? z.array(z.number().int().positive()).min(1, t(siteRequiredKey)) - : z.array(z.number().int().positive()).min(1); - - const formSchema = z - .object({ - name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), - siteIds: siteIdsSchema, - mode: z.enum(["host", "cidr", "http", "ssh"]), - destination: z.string().nullish(), - alias: z.string().nullish(), - destinationPort: z - .number() - .int() - .min(1) - .max(65535) - .optional() - .nullable(), - scheme: z.enum(["http", "https"]).optional(), - ssl: z.boolean().optional(), - httpConfigSubdomain: z.string().nullish(), - httpConfigDomainId: z.string().nullish(), - httpConfigFullDomain: z.string().nullish(), - niceId: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9-]+$/) - .optional(), - tcpPortRangeString: createPortRangeStringSchema(t), - udpPortRangeString: createPortRangeStringSchema(t), - disableIcmp: z.boolean().optional(), - authDaemonMode: z - .enum(["site", "remote", "native"]) - .optional() - .nullable(), - authDaemonPort: z.number().int().positive().optional().nullable(), - pamMode: z.enum(["passthrough", "push"]).optional().nullable(), - roles: z.array(tagSchema).optional(), - users: z.array(tagSchema).optional(), - clients: z - .array( - z.object({ - clientId: z.number(), - name: z.string() - }) - ) - .optional() - }) - .superRefine((data, ctx) => { - const isNativeSsh = - data.mode === "ssh" && data.authDaemonMode === "native"; - const trimmedDestination = data.destination?.trim(); - if ( - !isNativeSsh && - (!trimmedDestination || trimmedDestination.length < 1) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: destinationRequiredKey - ? t(destinationRequiredKey) - : "Destination is required", - path: ["destination"] - }); - } - if (data.mode === "ssh" && !isNativeSsh) { - if ( - data.destinationPort == null || - !Number.isFinite(data.destinationPort) || - data.destinationPort < 1 - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceHttpPortRequired"), - path: ["destinationPort"] - }); - } - } - if (data.mode !== "http") return; - if (!data.scheme) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceDownstreamSchemeRequired"), - path: ["scheme"] - }); - } - if ( - !isNativeSsh && - (data.destinationPort == null || - !Number.isFinite(data.destinationPort) || - data.destinationPort < 1) - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: t("internalResourceHttpPortRequired"), - path: ["destinationPort"] - }); - } - }); - - type FormData = z.infer; - - const clientsQuery = useQuery( - orgQueries.machineClients({ orgId, perPage: 1 }) - ); - const resourceRolesQuery = useQuery({ - ...resourceQueries.siteResourceRoles({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - const resourceUsersQuery = useQuery({ - ...resourceQueries.siteResourceUsers({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - const resourceClientsQuery = useQuery({ - ...resourceQueries.siteResourceClients({ - siteResourceId: siteResourceId ?? 0 - }), - enabled: siteResourceId != null - }); - - const allClients = (clientsQuery.data ?? []) - .filter((c) => !c.userId) - .map((c) => ({ id: c.clientId.toString(), text: c.name })); - - let formRoles: FormData["roles"] = []; - let formUsers: FormData["users"] = []; - let existingClients: FormData["clients"] = []; - if (siteResourceId != null) { - const rolesData = resourceRolesQuery.data; - const usersData = resourceUsersQuery.data; - const clientsData = resourceClientsQuery.data; - if (rolesData) { - formRoles = (rolesData as { roleId: number; name: string }[]) - .map((i) => ({ id: i.roleId.toString(), text: i.name })) - .filter((r) => r.text !== "Admin"); - } - if (usersData) { - formUsers = ( - usersData as { - userId: string; - email?: string; - username?: string; - type?: string; - idpName?: string; - }[] - ).map((i) => ({ - id: i.userId.toString(), - text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })); - } - if (clientsData) { - existingClients = [ - ...(clientsData as { clientId: number; name: string }[]) - ]; - } - } - - const loadingRolesUsers = - clientsQuery.isLoading || - (siteResourceId != null && - (resourceRolesQuery.isLoading || - resourceUsersQuery.isLoading || - resourceClientsQuery.isLoading)); - - const hasMachineClients = allClients.length > 0; - - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( - () => { - if (variant === "edit" && resource) { - return resource.authDaemonMode === "native" - ? "native" - : "standard"; - } - return "native"; - } - ); - - const [tcpPortMode, setTcpPortMode] = useState(() => - variant === "edit" && resource - ? getPortModeFromString(resource.tcpPortRangeString) - : "all" - ); - const [udpPortMode, setUdpPortMode] = useState(() => - variant === "edit" && resource - ? getPortModeFromString(resource.udpPortRangeString) - : "all" - ); - const [tcpCustomPorts, setTcpCustomPorts] = useState(() => - variant === "edit" && - resource && - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - const [udpCustomPorts, setUdpCustomPorts] = useState(() => - variant === "edit" && - resource && - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - - const defaultValues: FormData = - variant === "edit" && resource - ? { - name: resource.name, - siteIds: resource.siteIds, - mode: resource.mode ?? "host", - destination: resource.destination ?? "", - alias: resource.alias ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - authDaemonMode: - resource.authDaemonMode === "native" - ? "native" - : (resource.authDaemonMode ?? "site"), - authDaemonPort: resource.authDaemonPort ?? null, - pamMode: resource.pamMode ?? "passthrough", - destinationPort: resource.destinationPort ?? null, - scheme: resource.scheme ?? "http", - ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.subdomain ?? null, - httpConfigDomainId: resource.domainId ?? null, - httpConfigFullDomain: resource.fullDomain ?? null, - niceId: resource.niceId, - roles: [], - users: [], - clients: [] - } - : { - name: "", - siteIds: [], - mode: "host", - destination: "", - alias: null, - destinationPort: null, - scheme: "http", - ssl: true, - httpConfigSubdomain: null, - httpConfigDomainId: null, - httpConfigFullDomain: null, - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - authDaemonMode: "native", - authDaemonPort: null, - pamMode: "passthrough", - roles: [], - users: [], - clients: [] - }; - - const [selectedSites, setSelectedSites] = useState(() => - variant === "edit" && resource - ? buildSelectedSitesForResource(resource) - : [] - ); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues - }); - - const mode = form.watch("mode"); - const aliasValue = form.watch("alias"); - const httpConfigSubdomain = form.watch("httpConfigSubdomain"); - const httpConfigDomainId = form.watch("httpConfigDomainId"); - const httpConfigFullDomain = form.watch("httpConfigFullDomain"); - const isHttpMode = mode === "http"; - const isSshMode = mode === "ssh"; - const authDaemonMode = form.watch("authDaemonMode") ?? "site"; - const pamMode = form.watch("pamMode") ?? "passthrough"; - const isNative = sshServerMode === "native"; - const showDaemonLocation = - mode === "ssh" && !isNative && pamMode === "push"; - const showDaemonPort = - mode === "ssh" && - !isNative && - pamMode === "push" && - authDaemonMode === "remote"; - const aliasEndsWithLocal = - typeof aliasValue === "string" && - aliasValue.trim().toLowerCase().endsWith(".local"); - const hasInitialized = useRef(false); - const previousResourceId = useRef(null); - const initialSitesRef = useRef(initialSites); - initialSitesRef.current = initialSites; - - useEffect(() => { - const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts); - form.setValue("tcpPortRangeString", tcpValue); - }, [tcpPortMode, tcpCustomPorts, form]); - - useEffect(() => { - const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts); - form.setValue("udpPortRangeString", udpValue); - }, [udpPortMode, udpCustomPorts, form]); - - // Reset when create dialog opens - useEffect(() => { - if (variant === "create" && open) { - const prefillSites = - initialSitesRef.current.length > 0 - ? initialSitesRef.current - : []; - form.reset({ - name: "", - siteIds: prefillSites.map((s) => s.siteId), - mode: "host", - destination: "", - alias: null, - destinationPort: null, - scheme: "http", - ssl: true, - httpConfigSubdomain: null, - httpConfigDomainId: null, - httpConfigFullDomain: null, - tcpPortRangeString: "*", - udpPortRangeString: "*", - disableIcmp: false, - authDaemonMode: "native", - authDaemonPort: null, - pamMode: "passthrough", - roles: [], - users: [], - clients: [] - }); - setSelectedSites(prefillSites); - setSshServerMode("native"); - setTcpPortMode("all"); - setUdpPortMode("all"); - setTcpCustomPorts(""); - setUdpCustomPorts(""); - } - }, [variant, open, form]); - - // Reset when edit dialog opens / resource changes - useEffect(() => { - if (variant === "edit" && resource) { - const resourceChanged = previousResourceId.current !== resource.id; - if (resourceChanged) { - form.reset({ - name: resource.name, - siteIds: resource.siteIds, - mode: resource.mode ?? "host", - destination: resource.destination ?? "", - alias: resource.alias ?? null, - destinationPort: resource.destinationPort ?? null, - scheme: resource.scheme ?? "http", - ssl: resource.ssl ?? false, - httpConfigSubdomain: resource.subdomain ?? null, - httpConfigDomainId: resource.domainId ?? null, - httpConfigFullDomain: resource.fullDomain ?? null, - tcpPortRangeString: resource.tcpPortRangeString ?? "*", - udpPortRangeString: resource.udpPortRangeString ?? "*", - disableIcmp: resource.disableIcmp ?? false, - authDaemonMode: - resource.authDaemonMode === "native" - ? "native" - : (resource.authDaemonMode ?? "site"), - authDaemonPort: resource.authDaemonPort ?? null, - pamMode: resource.pamMode ?? "passthrough", - roles: [], - users: [], - clients: [] - }); - setSshServerMode( - resource.authDaemonMode === "native" ? "native" : "standard" - ); - setSelectedSites(buildSelectedSitesForResource(resource)); - setTcpPortMode( - getPortModeFromString(resource.tcpPortRangeString) - ); - setUdpPortMode( - getPortModeFromString(resource.udpPortRangeString) - ); - setTcpCustomPorts( - resource.tcpPortRangeString && - resource.tcpPortRangeString !== "*" - ? resource.tcpPortRangeString - : "" - ); - setUdpCustomPorts( - resource.udpPortRangeString && - resource.udpPortRangeString !== "*" - ? resource.udpPortRangeString - : "" - ); - previousResourceId.current = resource.id; - } - } - }, [variant, resource, form]); - - // When edit dialog closes, clear previousResourceId so next open (for any resource) resets from fresh data - useEffect(() => { - if (variant === "edit" && open === false) { - previousResourceId.current = null; - } - }, [variant, open]); - - // Populate roles/users/clients when edit data is loaded - useEffect(() => { - if ( - variant === "edit" && - siteResourceId != null && - !loadingRolesUsers && - !hasInitialized.current - ) { - hasInitialized.current = true; - form.setValue("roles", formRoles); - form.setValue("users", formUsers); - form.setValue("clients", existingClients); - } - }, [ - variant, - siteResourceId, - loadingRolesUsers, - formRoles, - formUsers, - existingClients, - form - ]); - - useEffect(() => { - onSubmitDisabledChange?.( - (isHttpMode && httpSectionDisabled) || - (isSshMode && sshSectionDisabled) - ); - }, [ - isHttpMode, - httpSectionDisabled, - isSshMode, - sshSectionDisabled, - onSubmitDisabledChange - ]); - - return ( -
- { - const siteIds = values.siteIds; - const trimmedDestination = values.destination?.trim(); - const isSshMode = values.mode === "ssh"; - onSubmit({ - ...values, - siteIds, - destination: - trimmedDestination && trimmedDestination.length > 0 - ? trimmedDestination - : null, - tcpPortRangeString: isSshMode - ? undefined - : values.tcpPortRangeString, - udpPortRangeString: isSshMode - ? undefined - : values.udpPortRangeString, - clients: (values.clients ?? []).map((c) => ({ - id: c.clientId.toString(), - text: c.name - })) - }); - })} - className="space-y-6" - id={formId} - > -
- ( - - {t(nameLabelKey)} - - - - - - )} - /> - {variant === "edit" && ( - ( - - {t("identifier")} - - - - - - )} - /> - )} -
- - -
-
-
- -
- {t( - "editInternalResourceDialogDestinationDescription" - )} -
-
-
-
-
- ( - - - {t("sites")} - - {mode === "ssh" && - (sshServerMode === - "native" || - (pamMode === "push" && - authDaemonMode === - "site")) ? ( - - - - - - - - { - setSelectedSites( - [ - site - ] - ); - field.onChange( - [ - site.siteId - ] - ); - }} - /> - - - ) : ( - - - - - - - - { - setSelectedSites( - sites - ); - field.onChange( - sites.map( - ( - s - ) => - s.siteId - ) - ); - }} - /> - - - )} - - - )} - /> -
-
- { - const modeOptions: OptionSelectOption[] = - [ - { - value: "host", - label: t( - modeHostKey - ) - }, - { - value: "cidr", - label: t( - modeCidrKey - ) - }, - ...(!disableEnterpriseFeatures - ? [ - { - value: "http" as const, - label: t( - modeHttpKey - ) - }, - { - value: "ssh" as const, - label: t( - modeSshKey - ) - } - ] - : []) - ]; - return ( - - - {t(modeLabelKey)} - - - options={ - modeOptions - } - value={field.value} - onChange={( - newMode - ) => { - field.onChange( - newMode - ); - if ( - newMode === - "ssh" - ) { - form.setValue( - "destinationPort", - 22 - ); - } else if ( - newMode === - "http" - ) { - form.setValue( - "destinationPort", - 443 - ); - } else { - form.setValue( - "destinationPort", - null - ); - } - }} - cols={2} - /> - - - ); - }} - /> -
-
- {selectedSites.length > 1 && ( -

- {t( - "internalResourceFormMultiSiteRoutingHelp" - )}{" "} - - {t( - "internalResourceFormMultiSiteRoutingHelpLearnMore" - )} - - - . -

- )} -
-
- {mode === "http" && ( -
- ( - - - {t(schemeLabelKey)} - - - - - )} - /> -
- )} - {((mode === "ssh" && - sshServerMode !== "native") || - mode === "http" || - mode === "host" || - mode === "cidr") && ( -
- ( - - - {t(destinationLabelKey)} - - - - field.onChange( - e.target - .value === - "" - ? null - : e - .target - .value - ) - } - /> - - - - )} - /> -
- )} - {(mode === "host" || mode === "ssh") && ( -
- ( - - - {t(aliasLabelKey)} - - - - - {aliasEndsWithLocal && ( -

- {t( - "internalResourceAliasLocalWarning" - )} -

- )} - -
- )} - /> -
- )} - {(mode === "http" || - (mode === "ssh" && - sshServerMode !== "native")) && ( -
- ( - - - {t( - destinationPortLabelKey - )} - - - { - const raw = - e.target - .value; - if ( - raw === "" - ) { - field.onChange( - null - ); - return; - } - const n = - Number(raw); - field.onChange( - Number.isFinite( - n - ) - ? n - : null - ); - }} - /> - - - - )} - /> -
- )} -
-
- - {(isHttpMode || isSshMode) && ( - - )} - - {isHttpMode ? ( -
-
- -
- {t(httpConfigurationDescriptionKey)} -
-
-
- { - if (res === null) { - form.setValue( - "httpConfigSubdomain", - null - ); - form.setValue( - "httpConfigDomainId", - null - ); - form.setValue( - "httpConfigFullDomain", - null - ); - return; - } - form.setValue( - "httpConfigSubdomain", - res.subdomain ?? null - ); - form.setValue( - "httpConfigDomainId", - res.domainId - ); - form.setValue( - "httpConfigFullDomain", - res.fullDomain - ); - }} - /> -
-
- ( - - - - - - )} - /> - {variant === "edit" && - resource?.domainId && - httpConfigFullDomain && - httpConfigDomainId === - resource.domainId && - httpConfigFullDomain === - resource.fullDomain && - build != "oss" && - form.watch("ssl") && ( -
- - {t("certificateStatus")}: - - -
- )} -
-
- ) : ( -
- {mode !== "ssh" && ( - <> -
- -
- {t( - "editInternalResourceDialogPortRestrictionsDescription" - )} -
-
-
-
- - {t( - "editInternalResourceDialogTcp" - )} - -
-
- ( - -
- - {tcpPortMode === - "custom" ? ( - - - setTcpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t( - "editInternalResourceDialogUdp" - )} - -
-
- ( - -
- - {udpPortMode === - "custom" ? ( - - - setUdpCustomPorts( - e - .target - .value - ) - } - /> - - ) : ( - - )} -
- -
- )} - /> -
-
-
-
- - {t( - "editInternalResourceDialogIcmp" - )} - -
-
- ( - -
- - - field.onChange( - !checked - ) - } - /> - - - {field.value - ? t( - "blocked" - ) - : t( - "allowed" - )} - -
- -
- )} - /> -
-
- - )} -
- )} -
- -
-
- -
- {t( - "editInternalResourceDialogAccessControlDescription" - )} -
-
- {loadingRolesUsers ? ( -
- {t("loading")} -
- ) : ( -
- ( - - {t("roles")} - - { - form.setValue( - "roles", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - - )} - /> - ( - - {t("users")} - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - /> - - - )} - /> - {hasMachineClients && ( - ( - - - {t("machineClients")} - - { - form.setValue( - "clients", - machines - ); - }} - /> - - - )} - /> - )} -
- )} -
- - {/* SSH Access tab (ssh mode only) */} - {!disableEnterpriseFeatures && mode === "ssh" && ( -
- - - {/* Mode */} -
-

- {t("sshServerMode")} -

- - value={sshServerMode} - options={[ - { - id: "native", - title: t("sshServerModePangolin"), - description: t( - "sshServerModeNativeDescription" - ), - disabled: sshSectionDisabled - }, - { - id: "standard", - title: t("sshServerModeStandard"), - description: t( - "sshServerModeStandardDescription" - ), - disabled: sshSectionDisabled - } - ]} - onChange={(v) => { - if (sshSectionDisabled) return; - setSshServerMode(v); - if (v === "native") { - form.setValue( - "authDaemonMode", - "native" - ); - form.setValue( - "authDaemonPort", - null - ); - // Trim to single site - if (selectedSites.length > 1) { - const first = - selectedSites.slice(0, 1); - setSelectedSites(first); - form.setValue( - "siteIds", - first.map((s) => s.siteId) - ); - } - } else { - form.setValue( - "authDaemonMode", - "site" - ); - } - }} - cols={2} - /> -
- -
-

- {t("sshAuthenticationMethod")} -

- ( - - - - value={ - field.value ?? - "passthrough" - } - options={[ - { - id: "passthrough", - title: t( - "sshAuthMethodManual" - ), - description: t( - "sshAuthMethodManualDescription" - ), - disabled: - sshSectionDisabled - }, - { - id: "push", - title: t( - "sshAuthMethodAutomated" - ), - description: t( - "sshAuthMethodAutomatedDescription" - ), - disabled: - sshSectionDisabled - } - ]} - onChange={(v) => { - if (sshSectionDisabled) - return; - field.onChange(v); - if ( - v === "passthrough" - ) { - form.setValue( - "authDaemonPort", - null - ); - } else if ( - v === "push" - ) { - // push + site (default) = single site - const curAuthMode = - form.getValues( - "authDaemonMode" - ); - if ( - curAuthMode !== - "remote" && - selectedSites.length > - 1 - ) { - const first = - selectedSites.slice( - 0, - 1 - ); - setSelectedSites( - first - ); - form.setValue( - "siteIds", - first.map( - (s) => - s.siteId - ) - ); - } - } - }} - cols={2} - /> - - - - )} - /> -
- - {/* Daemon Location (standard + push) */} - {showDaemonLocation && ( -
-

- {t("sshAuthDaemonLocation")} -

- ( - - - - value={ - (field.value as - | "site" - | "remote") ?? - "site" - } - options={[ - { - id: "site", - title: t( - "internalResourceAuthDaemonSite" - ), - description: t( - "sshDaemonLocationSiteDescription" - ), - disabled: - sshSectionDisabled - }, - { - id: "remote", - title: t( - "sshDaemonLocationRemote" - ), - description: t( - "sshDaemonLocationRemoteDescription" - ), - disabled: - sshSectionDisabled - } - ]} - onChange={(v) => { - if ( - sshSectionDisabled - ) - return; - field.onChange(v); - if (v === "site") { - form.setValue( - "authDaemonPort", - null - ); - // site daemon = single site - if ( - selectedSites.length > - 1 - ) { - const first = - selectedSites.slice( - 0, - 1 - ); - setSelectedSites( - first - ); - form.setValue( - "siteIds", - first.map( - ( - s - ) => - s.siteId - ) - ); - } - } - }} - cols={2} - /> - - - - )} - /> -

- {t("sshDaemonDisclaimer")}{" "} - - {t("learnMore")} - - -

-
- )} - - {/* Daemon Port (standard + push + remote) */} - {showDaemonPort && ( -
- ( - - - {t("sshDaemonPort")} - - - { - if ( - sshSectionDisabled - ) - return; - const v = - e.target.value; - if (v === "") { - field.onChange( - null - ); - return; - } - const num = - parseInt(v, 10); - field.onChange( - Number.isNaN( - num - ) - ? null - : num - ); - }} - /> - - - - )} - /> -
- )} -
- )} -
-
- - ); -} diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index ff854a1a8..38f58ba94 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -2,8 +2,6 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog"; -import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { ResourceSitesStatusCell, @@ -34,12 +32,14 @@ import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; +import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; import { ArrowDown01Icon, + ArrowRight, ArrowUp10Icon, ArrowUpDown, ChevronsUpDownIcon, @@ -47,6 +47,7 @@ import { MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; @@ -78,6 +79,7 @@ export type InternalResourceRow = { alias: string | null; aliasAddress: string | null; niceId: string; + enabled: boolean; tcpPortRangeString: string | null; udpPortRangeString: string | null; disableIcmp: boolean; @@ -140,13 +142,10 @@ export default function PrivateResourcesTable({ const api = createApiClient({ env }); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const [selectedInternalResource, setSelectedInternalResource] = useState(); - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [editingResource, setEditingResource] = - useState(); - const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -450,6 +449,17 @@ export default function PrivateResourcesTable({ + + + {t("viewSettings")} + + { setSelectedInternalResource( @@ -464,15 +474,17 @@ export default function PrivateResourcesTable({ - + +
); } @@ -580,8 +592,15 @@ export default function PrivateResourcesTable({ tableId="internal-resources" searchPlaceholder={t("resourcesSearch")} searchQuery={searchParams.get("query")?.toString()} - onAdd={() => setIsCreateDialogOpen(true)} + onAdd={() => + startNavigation(() => + router.push( + `/${orgId}/settings/resources/private/create` + ) + ) + } addButtonText={t("resourceAdd")} + isNavigatingToAddPage={isNavigatingToAddPage} onSearch={handleSearchChange} onRefresh={refreshData} onPaginationChange={handlePaginationChange} @@ -597,34 +616,6 @@ export default function PrivateResourcesTable({ stickyLeftColumn="name" stickyRightColumn="actions" /> - - {editingResource && ( - { - // Delay refresh to allow modal to close smoothly - setTimeout(() => { - router.refresh(); - setEditingResource(null); - }, 150); - }} - /> - )} - - { - // Delay refresh to allow modal to close smoothly - setTimeout(() => { - router.refresh(); - }, 150); - }} - /> ); } diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/SiteResourceInfoBox.tsx new file mode 100644 index 000000000..36b7f29ad --- /dev/null +++ b/src/components/SiteResourceInfoBox.tsx @@ -0,0 +1,232 @@ +"use client"; + +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext"; +import { formatPortRestrictionDisplay } from "@app/lib/launcherResourceDetails"; +import { + formatSiteResourceAccess, + formatSiteResourceDestinationDisplay, + isSafeUrlForLink, + type LauncherAccessFields +} from "@app/lib/launcherResourceAccess"; +import type { PrivateResourceMode } from "@app/lib/privateResourceForm"; +import { useTranslations } from "next-intl"; + +type SiteResourceInfoInput = { + mode: PrivateResourceMode; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain?: string | null; + alias?: string | null; + aliasAddress?: string | null; + authDaemonMode?: "site" | "remote" | "native" | null; + tcpPortRangeString?: string | null; + udpPortRangeString?: string | null; +}; + +type SiteResourceInfoBoxVariant = "settings" | "panel"; + +type SiteResourceInfoSectionsProps = { + siteResource: SiteResourceInfoInput; + access: LauncherAccessFields; + variant: SiteResourceInfoBoxVariant; + accessClassName?: string; +}; + +function AccessMethodContent({ + accessDisplay, + accessCopyValue, + accessUrl, + className +}: LauncherAccessFields & { className?: string }) { + if (!accessDisplay) { + return -; + } + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + + if (canLink && href) { + return ( + + ); + } + + return ( + + ); +} + +export function SiteResourceInfoSections({ + siteResource, + access, + variant, + accessClassName +}: SiteResourceInfoSectionsProps) { + const t = useTranslations(); + const isPanel = variant === "panel"; + + const modeLabel: Record = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + http: t("editInternalResourceDialogModeHttp"), + ssh: t("editInternalResourceDialogModeSsh") + }; + + const destination = formatSiteResourceDestinationDisplay({ + mode: siteResource.mode, + destination: siteResource.destination, + destinationPort: siteResource.destinationPort, + scheme: siteResource.scheme + }); + + const portRestrictions = formatPortRestrictionDisplay({ + tcpPortRangeString: siteResource.tcpPortRangeString ?? "*", + udpPortRangeString: siteResource.udpPortRangeString ?? "*" + }); + const showAlias = + siteResource.mode !== "cidr" && siteResource.mode !== "http"; + const showDestination = !( + siteResource.mode === "ssh" && siteResource.authDaemonMode === "native" + ); + + const numSections = + 2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (isPanel ? 1 : 0); + + const sections = ( + + + {t("type")} + + {modeLabel[siteResource.mode]} + + + + + {t("access")} + + + + + + {showDestination ? ( + + + {t("editInternalResourceDialogDestination")} + + + {destination || "-"} + + + ) : null} + + {showAlias ? ( + + + {t("editInternalResourceDialogAlias")} + + + {siteResource.alias?.trim() ? siteResource.alias : "-"} + + + ) : null} + + {isPanel ? ( + + {t("portRestrictions")} + + {!portRestrictions.hasNonDefaultPorts ? ( + + {t("resourceLauncherNoPortRestrictions")} + + ) : ( +
+ {portRestrictions.tcp.state !== "all" ? ( +
+ {t("resourceLauncherTcp")}:{" "} + {portRestrictions.tcp.state === + "blocked" + ? t("blocked") + : portRestrictions.tcp.ports} +
+ ) : null} + {portRestrictions.udp.state !== "all" ? ( +
+ {t("resourceLauncherUdp")}:{" "} + {portRestrictions.udp.state === + "blocked" + ? t("blocked") + : portRestrictions.udp.ports} +
+ ) : null} +
+ )} +
+
+ ) : null} +
+ ); + + if (isPanel) { + return sections; + } + + return ( + + {sections} + + ); +} + +type SiteResourceInfoBoxProps = { + variant?: "settings"; +}; + +export default function SiteResourceInfoBox({ + variant = "settings" +}: SiteResourceInfoBoxProps) { + const { siteResource } = useSiteResourceContext(); + + const access = formatSiteResourceAccess({ + mode: siteResource.mode, + destination: siteResource.destination, + destinationPort: siteResource.destinationPort, + scheme: siteResource.scheme, + ssl: siteResource.ssl, + fullDomain: siteResource.fullDomain ?? null, + alias: siteResource.alias ?? null, + aliasAddress: siteResource.aliasAddress ?? null + }); + + return ( + + ); +} diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index 6502f331a..d861a870c 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -7,6 +7,7 @@ import { SettingsContainer } from "@app/components/Settings"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; +import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { ListResourcesResponse } from "@server/routers/resource"; import type ResponseT from "@server/types/Response"; @@ -432,19 +433,13 @@ export default function SiteResourcesOverview({ editHref: `/${orgId}/settings/resources/public/${r.niceId}` })); - const privateRows = privateList.map((row) => { - const qs = new URLSearchParams({ - siteId: String(siteId), - query: row.niceId - }); - return { - key: row.siteResourceId, - meta: , - name: row.name, - access: , - editHref: `/${orgId}/settings/resources/private?${qs.toString()}` - }; - }); + const privateRows = privateList.map((row) => ({ + key: row.siteResourceId, + meta: , + name: row.name, + access: , + editHref: getPrivateResourceSettingsHref(orgId, row.niceId) + })); if (showEmptyPlaceholder) { return ( diff --git a/src/components/SshServerSettingsFields.tsx b/src/components/SshServerSettingsFields.tsx new file mode 100644 index 000000000..8441b25f0 --- /dev/null +++ b/src/components/SshServerSettingsFields.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { SettingsFormCell } from "@app/components/Settings"; +import { + StrategySelect, + type StrategyOption +} from "@app/components/StrategySelect"; +import { Badge } from "@app/components/ui/badge"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { ExternalLink } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo } from "react"; + +export type SshServerSettingsFormFields = { + pamMode: "passthrough" | "push"; + standardDaemonLocation: "site" | "remote"; + authDaemonPort: string; +}; + +type SshServerSettingsFieldsProps = { + pamMode: "passthrough" | "push"; + standardDaemonLocation: "site" | "remote"; + authDaemonPort: string; + onPamModeChange: (value: "passthrough" | "push") => void; + onStandardDaemonLocationChange: (value: "site" | "remote") => void; + onAuthDaemonPortChange: (value: string) => void; + authDaemonPortError?: string; + sshServerMode: "standard" | "native"; + serverModeDisplay: "badge" | "select"; + onServerModeChange?: (mode: "standard" | "native") => void; + sshServerModeOptions?: StrategyOption<"standard" | "native">[]; + idPrefix?: string; +}; + +export function SshServerSettingsFields({ + pamMode, + standardDaemonLocation, + authDaemonPort, + onPamModeChange, + onStandardDaemonLocationChange, + onAuthDaemonPortChange, + authDaemonPortError, + sshServerMode, + serverModeDisplay, + onServerModeChange, + sshServerModeOptions, + idPrefix = "ssh-server" +}: SshServerSettingsFieldsProps) { + const t = useTranslations(); + const isNative = sshServerMode === "native"; + const showDaemonLocation = !isNative && pamMode === "push"; + const showDaemonPort = + !isNative && pamMode === "push" && standardDaemonLocation === "remote"; + + const authMethodOptions = useMemo( + (): StrategyOption<"passthrough" | "push">[] => [ + { + id: "passthrough", + title: t("sshAuthMethodManual"), + description: t("sshAuthMethodManualDescription") + }, + { + id: "push", + title: t("sshAuthMethodAutomated"), + description: t("sshAuthMethodAutomatedDescription") + } + ], + [t] + ); + + const daemonLocationOptions = useMemo( + (): StrategyOption<"site" | "remote">[] => [ + { + id: "site", + title: t("internalResourceAuthDaemonSite"), + description: t("sshDaemonLocationSiteDescription") + }, + { + id: "remote", + title: t("sshDaemonLocationRemote"), + description: t("sshDaemonLocationRemoteDescription") + } + ], + [t] + ); + + const defaultSshServerModeOptions = useMemo( + (): StrategyOption<"standard" | "native">[] => [ + { + id: "native", + title: t("sshServerModePangolin"), + description: t("sshServerModeNativeDescription") + }, + { + id: "standard", + title: t("sshServerModeStandard"), + description: t("sshServerModeStandardDescription") + } + ], + [t] + ); + + const modeOptions = sshServerModeOptions ?? defaultSshServerModeOptions; + + return ( + <> + +
+

+ {t("sshServerMode")} +

+ {serverModeDisplay === "badge" ? ( + + {sshServerMode === "standard" + ? t("sshServerModeStandard") + : t("sshServerModePangolin")} + + ) : ( + + idPrefix={`${idPrefix}-mode`} + value={sshServerMode} + options={modeOptions} + onChange={(value) => onServerModeChange?.(value)} + cols={2} + /> + )} +
+
+ + +
+

+ {t("sshAuthenticationMethod")} +

+ + idPrefix={`${idPrefix}-auth`} + value={pamMode} + options={authMethodOptions} + onChange={onPamModeChange} + cols={2} + /> +
+
+ + {showDaemonLocation && ( + +
+

+ {t("sshAuthDaemonLocation")} +

+ + idPrefix={`${idPrefix}-daemon`} + value={standardDaemonLocation} + options={daemonLocationOptions} + onChange={onStandardDaemonLocationChange} + cols={2} + /> +

+ {t("sshDaemonDisclaimer")}{" "} + + {t("learnMore")} + + +

+
+
+ )} + + {showDaemonPort && ( + +
+ + + onAuthDaemonPortChange(e.target.value) + } + /> + {authDaemonPortError ? ( +

+ {authDaemonPortError} +

+ ) : null} +
+
+ )} + + ); +} diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index b4cd961d4..81e9356f2 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -18,6 +18,7 @@ interface StrategySelectProps { defaultValue?: TValue; onChange?: (value: TValue) => void; cols?: number; + idPrefix?: string; } export function StrategySelect({ @@ -25,7 +26,8 @@ export function StrategySelect({ value: controlledValue, defaultValue, onChange, - cols = 1 + cols = 1, + idPrefix = "strategy" }: StrategySelectProps) { const [uncontrolledSelected, setUncontrolledSelected] = useState< TValue | undefined @@ -49,43 +51,49 @@ export function StrategySelect({ }} className="grid md:grid-cols-(--cols) gap-4" > - {options.map((option: StrategyOption) => ( -
)} Date: Mon, 6 Jul 2026 22:00:35 -0400 Subject: [PATCH 21/53] includen on sso resources in launcher --- .../launcher/launcherResourceAccess.ts | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 246f86688..0784dff70 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -1,10 +1,11 @@ import { createHash } from "node:crypto"; -import { db } from "@server/db"; +import { alias, db } from "@server/db"; import { exitNodes, labels, launcherViews, resourceLabels, + resourcePolicies, resources, rolePolicies, roleResources, @@ -31,6 +32,7 @@ import { inArray, isNull, like, + not, or, sql, type SQL @@ -134,6 +136,48 @@ function launcherAccessibleIdsCacheKey( return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`; } +async function fetchOrgNonSsoPublicResourceIds( + orgId: string +): Promise { + const sharedPolicy = alias(resourcePolicies, "sharedPolicy"); + const defaultPolicy = alias(resourcePolicies, "defaultPolicy"); + + const effectiveSso = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso} + ELSE ${defaultPolicy.sso} + END, + false + ) + `; + + const rows = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .leftJoin( + sharedPolicy, + eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId) + ) + .leftJoin( + defaultPolicy, + eq( + defaultPolicy.resourcePolicyId, + resources.defaultResourcePolicyId + ) + ) + .where( + and( + eq(resources.orgId, orgId), + eq(resources.enabled, true), + eq(resources.blockAccess, false), + not(effectiveSso) + ) + ); + + return rows.map((row) => row.resourceId); +} + async function resolveAccessibleIdsUncached( orgId: string, userId: string, @@ -145,7 +189,8 @@ async function resolveAccessibleIdsUncached( directPolicyResourceResults, rolePolicyResourceResults, directSiteResourceResults, - roleSiteResourceResults + roleSiteResourceResults, + orgNonSsoPublicResources ] = await Promise.all([ db .select({ resourceId: userResources.resourceId }) @@ -214,7 +259,8 @@ async function resolveAccessibleIdsUncached( }) .from(roleSiteResources) .where(inArray(roleSiteResources.roleId, userRoleIds)) - : Promise.resolve([]) + : Promise.resolve([]), + fetchOrgNonSsoPublicResourceIds(orgId) ]); return { @@ -223,7 +269,8 @@ async function resolveAccessibleIdsUncached( ...directResources.map((r) => r.resourceId), ...roleResourceResults.map((r) => r.resourceId), ...directPolicyResourceResults.map((r) => r.resourceId), - ...rolePolicyResourceResults.map((r) => r.resourceId) + ...rolePolicyResourceResults.map((r) => r.resourceId), + ...orgNonSsoPublicResources ]) ), siteResourceIds: Array.from( From beaa5735ce0e9f3d878e914afdac2b438b7bf4b0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 7 Jul 2026 14:51:15 -0400 Subject: [PATCH 22/53] move cert to info box and add network access to create wizard --- messages/en-US.json | 1 + .../private/PrivateResourceHttpFields.tsx | 30 --------------- .../resources/private/[niceId]/http/page.tsx | 6 --- .../resources/private/create/page.tsx | 23 ++++++++++++ src/components/LayoutMobileMenu.tsx | 2 +- src/components/LayoutSidebar.tsx | 4 +- src/components/SiteResourceInfoBox.tsx | 37 ++++++++++++++++++- src/lib/privateResourceForm.ts | 2 + 8 files changed, 65 insertions(+), 40 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 003fd74a4..196fdf4b9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3585,6 +3585,7 @@ "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "resourceLauncherTitle": "Resource Launcher", + "resourceSidebarLauncherTitle": "Launcher", "resourceLauncherDescription": "View all available resources and launch them from one central hub", "resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherDefaultView": "Default", diff --git a/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx b/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx index 814adecb1..43fb49c08 100644 --- a/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx +++ b/src/app/[orgId]/settings/resources/private/PrivateResourceHttpFields.tsx @@ -1,6 +1,5 @@ "use client"; -import CertificateStatus from "@app/components/CertificateStatus"; import DomainPicker from "@app/components/DomainPicker"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { @@ -27,7 +26,6 @@ import { SelectValue } from "@app/components/ui/select"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -import { build } from "@server/build"; import { useTranslations } from "next-intl"; import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form"; @@ -38,8 +36,6 @@ type PrivateResourceHttpFieldsProps = { watch: UseFormWatch; disabled?: boolean; siteResourceId?: number; - resourceDomainId?: string | null; - resourceFullDomain?: string | null; labelPrefix?: "create" | "edit"; hideDomainPicker?: boolean; hidePaidFeaturesAlert?: boolean; @@ -52,8 +48,6 @@ export function PrivateResourceHttpFields({ watch, disabled = false, siteResourceId, - resourceDomainId, - resourceFullDomain, labelPrefix = "edit", hideDomainPicker = false, hidePaidFeaturesAlert = false @@ -91,7 +85,6 @@ export function PrivateResourceHttpFields({ const httpConfigSubdomain = watch("httpConfigSubdomain"); const httpConfigDomainId = watch("httpConfigDomainId"); const httpConfigFullDomain = watch("httpConfigFullDomain"); - const ssl = watch("ssl"); return ( @@ -274,29 +267,6 @@ export function PrivateResourceHttpFields({ )} /> - {siteResourceId && - resourceDomainId && - httpConfigFullDomain && - httpConfigDomainId === resourceDomainId && - httpConfigFullDomain === resourceFullDomain && - build != "oss" && - ssl && ( - -
- - {t("certificateStatus")}: - - -
-
- )} )} diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx index d69210ef5..2bbd776e5 100644 --- a/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx +++ b/src/app/[orgId]/settings/resources/private/[niceId]/http/page.tsx @@ -122,12 +122,6 @@ export default function PrivateResourceHttpPage() { watch={asAnyWatch(form.watch)} disabled={httpSectionDisabled} siteResourceId={siteResource.id} - resourceDomainId={ - siteResource.domainId - } - resourceFullDomain={ - siteResource.fullDomain - } />
diff --git a/src/app/[orgId]/settings/resources/private/create/page.tsx b/src/app/[orgId]/settings/resources/private/create/page.tsx index f8109fd66..49b40e3c9 100644 --- a/src/app/[orgId]/settings/resources/private/create/page.tsx +++ b/src/app/[orgId]/settings/resources/private/create/page.tsx @@ -53,6 +53,7 @@ import { z } from "zod"; import { PrivateResourceSitesField } from "../PrivateResourceSitesField"; import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields"; import { PrivateResourceSshFields } from "../PrivateResourceSshFields"; +import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges"; import { PrivateResourceAliasField, PrivateResourceCidrDestinationField, @@ -105,6 +106,8 @@ export default function CreatePrivateResourcePage() { standardDaemonLocation: "site", authDaemonPort: null, pamMode: "passthrough", + tcpPortRangeString: "*", + udpPortRangeString: "*", disableIcmp: false } }); @@ -426,6 +429,16 @@ export default function CreatePrivateResourcePage() { hideAlias /> + + + @@ -466,6 +479,16 @@ export default function CreatePrivateResourcePage() { labelPrefix="create" /> + + + diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index b549d1f2e..7d7ebb902 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -155,7 +155,7 @@ export function LayoutMobileMenu({ {t( - "resourceLauncherTitle" + "resourceSidebarLauncherTitle" )} diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 9175cedc4..c42e2c1fa 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -195,7 +195,7 @@ export function LayoutSidebar({ sideOffset={8} >

- {t("resourceLauncherTitle")} + {t("resourceSidebarLauncherTitle")}

@@ -211,7 +211,7 @@ export function LayoutSidebar({ - {t("resourceLauncherTitle")} + {t("resourceSidebarLauncherTitle")} )} diff --git a/src/components/SiteResourceInfoBox.tsx b/src/components/SiteResourceInfoBox.tsx index 36b7f29ad..f57acbe99 100644 --- a/src/components/SiteResourceInfoBox.tsx +++ b/src/components/SiteResourceInfoBox.tsx @@ -1,5 +1,6 @@ "use client"; +import CertificateStatus from "@app/components/CertificateStatus"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, @@ -17,14 +18,17 @@ import { type LauncherAccessFields } from "@app/lib/launcherResourceAccess"; import type { PrivateResourceMode } from "@app/lib/privateResourceForm"; +import { build } from "@server/build"; import { useTranslations } from "next-intl"; type SiteResourceInfoInput = { + orgId: string; mode: PrivateResourceMode; destination: string | null; destinationPort: number | null; scheme: "http" | "https" | null; ssl: boolean; + domainId?: string | null; fullDomain?: string | null; alias?: string | null; aliasAddress?: string | null; @@ -108,9 +112,20 @@ export function SiteResourceInfoSections({ const showDestination = !( siteResource.mode === "ssh" && siteResource.authDaemonMode === "native" ); + const showCertificate = !!( + siteResource.mode === "http" && + siteResource.ssl && + siteResource.domainId && + siteResource.fullDomain && + build != "oss" + ); const numSections = - 2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + (isPanel ? 1 : 0); + 2 + + (showDestination ? 1 : 0) + + (showAlias ? 1 : 0) + + (showCertificate ? 1 : 0) + + (isPanel ? 1 : 0); const sections = ( @@ -155,6 +170,26 @@ export function SiteResourceInfoSections({ ) : null} + {showCertificate ? ( + + + {t("certificateStatus", { + defaultValue: "Certificate" + })} + + + + + + ) : null} + {isPanel ? ( {t("portRestrictions")} diff --git a/src/lib/privateResourceForm.ts b/src/lib/privateResourceForm.ts index ecac87e88..d9f6fd691 100644 --- a/src/lib/privateResourceForm.ts +++ b/src/lib/privateResourceForm.ts @@ -400,6 +400,8 @@ export function createCreateFormSchema(t: TranslateFn) { .nullable(), authDaemonPort: z.number().int().positive().optional().nullable(), pamMode: z.enum(["passthrough", "push"]).optional().nullable(), + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional() }) .superRefine((data, ctx) => { From 417438c209aad5769d41a5cfbae1b8e0a1f37141 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 7 Jul 2026 14:04:34 -0400 Subject: [PATCH 23/53] Remove feature flags from labels --- server/lib/billing/tierMatrix.ts | 2 - server/private/routers/external.ts | 54 -------- server/private/routers/labels/index.ts | 19 --- server/routers/client/listClients.ts | 38 +++--- server/routers/external.ts | 43 +++++++ .../routers/labels/attachLabelToItem.ts | 13 -- .../routers/labels/createOrgLabel.ts | 12 -- .../routers/labels/deleteOrgLabel.ts | 12 -- .../routers/labels/detachLabelFromItem.ts | 13 -- server/routers/labels/index.ts | 6 + .../routers/labels/listOrgLabels.ts | 13 -- .../routers/labels/updateOrgLabel.ts | 13 -- .../launcher/launcherResourceAccess.ts | 115 +++++------------- server/routers/resource/getUserResources.ts | 85 ++++++------- server/routers/resource/listResources.ts | 74 +++++------ .../resource/listUserResourceAliases.ts | 9 +- server/routers/site/listSites.ts | 71 +++++------ .../siteResource/listAllSiteResourcesByOrg.ts | 38 +++--- .../settings/{(private) => }/labels/page.tsx | 4 - src/components/MachineClientsTable.tsx | 14 +-- src/components/OrgLabelsTable.tsx | 3 - src/components/PrivateResourcesTable.tsx | 14 +-- src/components/PublicResourcesTable.tsx | 14 +-- src/components/SitesTable.tsx | 20 ++- 24 files changed, 233 insertions(+), 466 deletions(-) delete mode 100644 server/private/routers/labels/index.ts rename server/{private => }/routers/labels/attachLabelToItem.ts (94%) rename server/{private => }/routers/labels/createOrgLabel.ts (91%) rename server/{private => }/routers/labels/deleteOrgLabel.ts (82%) rename server/{private => }/routers/labels/detachLabelFromItem.ts (94%) create mode 100644 server/routers/labels/index.ts rename server/{private => }/routers/labels/listOrgLabels.ts (91%) rename server/{private => }/routers/labels/updateOrgLabel.ts (89%) rename src/app/[orgId]/settings/{(private) => }/labels/page.tsx (90%) diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index f0e6dc95a..7c0b591ca 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -23,7 +23,6 @@ export enum TierFeature { StandaloneHealthChecks = "standaloneHealthChecks", AlertingRules = "alertingRules", WildcardSubdomain = "wildcardSubdomain", - Labels = "labels", NewtAutoUpdate = "newtAutoUpdate", ResourcePolicies = "resourcePolicies", AdvancedPublicResources = "advancedPublicResources", @@ -31,7 +30,6 @@ export enum TierFeature { } export const tierMatrix: Record = { - [TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index c6ee3de55..75a5b8d48 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; import * as resource from "#private/routers/resource"; import * as policy from "#private/routers/policy"; @@ -810,59 +809,6 @@ authenticated.get( alertRule.getAlertRule ); -authenticated.get( - "/org/:orgId/labels", - verifyValidLicense, - verifyOrgAccess, - verifyValidSubscription(tierMatrix.labels), - verifyUserHasAction(ActionsEnum.listOrgLabels), - labels.listOrgLabels -); - -authenticated.post( - "/org/:orgId/labels", - verifyValidLicense, - verifyOrgAccess, - verifyValidSubscription(tierMatrix.labels), - verifyUserHasAction(ActionsEnum.createOrgLabel), - labels.createOrgLabel -); - -authenticated.patch( - "/org/:orgId/label/:labelId", - verifyValidLicense, - verifyOrgAccess, - verifyValidSubscription(tierMatrix.labels), - verifyUserHasAction(ActionsEnum.updateOrgLabel), - labels.updateOrgLabel -); - -authenticated.delete( - "/org/:orgId/label/:labelId", - verifyValidLicense, - verifyOrgAccess, - verifyUserHasAction(ActionsEnum.deleteOrgLabel), - labels.deleteOrgLabel -); - -authenticated.put( - "/org/:orgId/label/:labelId/attach", - verifyValidLicense, - verifyOrgAccess, - verifyValidSubscription(tierMatrix.labels), - verifyUserHasAction(ActionsEnum.attachLabelToItem), - labels.attachLabelToItem -); - -authenticated.put( - "/org/:orgId/label/:labelId/detach", - verifyValidLicense, - verifyOrgAccess, - verifyValidSubscription(tierMatrix.labels), - verifyUserHasAction(ActionsEnum.detachLabelFromItem), - labels.detachLabelFromItem -); - authenticated.get( "/org/:orgId/health-checks", verifyValidLicense, diff --git a/server/private/routers/labels/index.ts b/server/private/routers/labels/index.ts deleted file mode 100644 index d988d8e38..000000000 --- a/server/private/routers/labels/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -export * from "./listOrgLabels"; -export * from "./createOrgLabel"; -export * from "./updateOrgLabel"; -export * from "./attachLabelToItem"; -export * from "./detachLabelFromItem"; -export * from "./deleteOrgLabel"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 98c0fc550..3a05c41c5 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -304,11 +304,6 @@ export async function listClients( (client) => client.clientId ); - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - // Get client count with filter const conditions = [ and( @@ -341,7 +336,7 @@ export async function listClients( conditions.push(or(...filterAggregates)); } - if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( clients.clientId, @@ -361,25 +356,20 @@ export async function listClients( const q = "%" + query.toLowerCase() + "%"; const queryList = [ like(sql`LOWER(${clients.name})`, q), - like(sql`LOWER(${clients.niceId})`, q) + like(sql`LOWER(${clients.niceId})`, q), + inArray( + clients.clientId, + db + .select({ id: clientLabels.clientId }) + .from(clientLabels) + .innerJoin( + labels, + eq(labels.labelId, clientLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) ]; - if (isLabelFeatureEnabled) { - queryList.push( - inArray( - clients.clientId, - db - .select({ id: clientLabels.clientId }) - .from(clientLabels) - .innerJoin( - labels, - eq(labels.labelId, clientLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, q)) - ) - ); - } - conditions.push(or(...queryList)); } @@ -414,7 +404,7 @@ export async function listClients( clientId: number; }> = []; - if (isLabelFeatureEnabled && clientIds.length > 0) { + if (clientIds.length > 0) { labelsForClients = await db .select({ labelId: labels.labelId, diff --git a/server/routers/external.ts b/server/routers/external.ts index 8a22393ca..9e4c5b3f1 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -54,6 +54,7 @@ import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; import { logActionAudit } from "#dynamic/middlewares"; import { checkRoundTripMessage } from "./ws"; +import * as labels from "@server/routers/labels"; // Root routes export const unauthenticated = Router(); @@ -1335,6 +1336,48 @@ authenticated.get( authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); +authenticated.get( + "/org/:orgId/labels", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listOrgLabels), + labels.listOrgLabels +); + +authenticated.post( + "/org/:orgId/labels", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgLabel), + labels.createOrgLabel +); + +authenticated.patch( + "/org/:orgId/label/:labelId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgLabel), + labels.updateOrgLabel +); + +authenticated.delete( + "/org/:orgId/label/:labelId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteOrgLabel), + labels.deleteOrgLabel +); + +authenticated.put( + "/org/:orgId/label/:labelId/attach", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.attachLabelToItem), + labels.attachLabelToItem +); + +authenticated.put( + "/org/:orgId/label/:labelId/detach", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.detachLabelFromItem), + labels.detachLabelFromItem +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/server/private/routers/labels/attachLabelToItem.ts b/server/routers/labels/attachLabelToItem.ts similarity index 94% rename from server/private/routers/labels/attachLabelToItem.ts rename to server/routers/labels/attachLabelToItem.ts index d011a606d..0d9e49b35 100644 --- a/server/private/routers/labels/attachLabelToItem.ts +++ b/server/routers/labels/attachLabelToItem.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { clients, clientLabels, diff --git a/server/private/routers/labels/createOrgLabel.ts b/server/routers/labels/createOrgLabel.ts similarity index 91% rename from server/private/routers/labels/createOrgLabel.ts rename to server/routers/labels/createOrgLabel.ts index c856eecf4..6fa20816e 100644 --- a/server/private/routers/labels/createOrgLabel.ts +++ b/server/routers/labels/createOrgLabel.ts @@ -1,15 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ import { db, labels, diff --git a/server/private/routers/labels/deleteOrgLabel.ts b/server/routers/labels/deleteOrgLabel.ts similarity index 82% rename from server/private/routers/labels/deleteOrgLabel.ts rename to server/routers/labels/deleteOrgLabel.ts index f091c910a..c46e67437 100644 --- a/server/private/routers/labels/deleteOrgLabel.ts +++ b/server/routers/labels/deleteOrgLabel.ts @@ -1,15 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ import { db, labels } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/labels/detachLabelFromItem.ts b/server/routers/labels/detachLabelFromItem.ts similarity index 94% rename from server/private/routers/labels/detachLabelFromItem.ts rename to server/routers/labels/detachLabelFromItem.ts index 9a5545312..52c752ca8 100644 --- a/server/private/routers/labels/detachLabelFromItem.ts +++ b/server/routers/labels/detachLabelFromItem.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { clients, clientLabels, diff --git a/server/routers/labels/index.ts b/server/routers/labels/index.ts new file mode 100644 index 000000000..aa8c34222 --- /dev/null +++ b/server/routers/labels/index.ts @@ -0,0 +1,6 @@ +export * from "./listOrgLabels"; +export * from "./createOrgLabel"; +export * from "./updateOrgLabel"; +export * from "./attachLabelToItem"; +export * from "./detachLabelFromItem"; +export * from "./deleteOrgLabel"; diff --git a/server/private/routers/labels/listOrgLabels.ts b/server/routers/labels/listOrgLabels.ts similarity index 91% rename from server/private/routers/labels/listOrgLabels.ts rename to server/routers/labels/listOrgLabels.ts index dc2b50017..6d6b853cf 100644 --- a/server/private/routers/labels/listOrgLabels.ts +++ b/server/routers/labels/listOrgLabels.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { db, labels } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/private/routers/labels/updateOrgLabel.ts b/server/routers/labels/updateOrgLabel.ts similarity index 89% rename from server/private/routers/labels/updateOrgLabel.ts rename to server/routers/labels/updateOrgLabel.ts index 134a01f02..fe8a6f573 100644 --- a/server/private/routers/labels/updateOrgLabel.ts +++ b/server/routers/labels/updateOrgLabel.ts @@ -1,16 +1,3 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025-2026 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - import { db, labels } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 246f86688..ec4500262 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -274,10 +274,7 @@ function combineOrConditions( return or(...parts); } -function buildSearchConditionForPublic( - query: string, - labelsFeatureEnabled: boolean -) { +function buildSearchConditionForPublic(query: string) { if (!query.trim()) { return undefined; } @@ -295,32 +292,21 @@ function buildSearchConditionForPublic( .leftJoin(sites, eq(targets.siteId, sites.siteId)) .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .where(like(sql`LOWER(${exitNodes.endpoint})`, pattern)) + ), + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin(labels, eq(labels.labelId, resourceLabels.labelId)) + .where(like(sql`LOWER(${labels.name})`, pattern)) ) ]; - if (labelsFeatureEnabled) { - queryList.push( - inArray( - resources.resourceId, - db - .select({ id: resourceLabels.resourceId }) - .from(resourceLabels) - .innerJoin( - labels, - eq(labels.labelId, resourceLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, pattern)) - ) - ); - } - return or(...queryList); } -function buildSearchConditionForSiteResource( - query: string, - labelsFeatureEnabled: boolean -) { +function buildSearchConditionForSiteResource(query: string) { if (!query.trim()) { return undefined; } @@ -335,40 +321,34 @@ function buildSearchConditionForSiteResource( like(sql`LOWER(${siteResources.scheme})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern), - like(sql`LOWER(${siteResources.aliasAddress})`, pattern) + like(sql`LOWER(${siteResources.aliasAddress})`, pattern), + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) ]; - if (labelsFeatureEnabled) { - queryList.push( - inArray( - siteResources.siteResourceId, - db - .select({ id: siteResourceLabels.siteResourceId }) - .from(siteResourceLabels) - .innerJoin( - labels, - eq(labels.labelId, siteResourceLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, pattern)) - ) - ); - } - return or(...queryList); } async function filterPublicResourceIdsByTextSearch( orgId: string, resourceIds: number[], - query: string, - labelsFeatureEnabled: boolean + query: string ): Promise { if (!query.trim() || resourceIds.length === 0) { return resourceIds; } const textMatch = combineOrConditions( - buildSearchConditionForPublic(query, labelsFeatureEnabled), + buildSearchConditionForPublic(query), buildSiteNameSearchCondition(query) ); if (!textMatch) { @@ -395,15 +375,14 @@ async function filterPublicResourceIdsByTextSearch( async function filterSiteResourceIdsByTextSearch( orgId: string, siteResourceIds: number[], - query: string, - labelsFeatureEnabled: boolean + query: string ): Promise { if (!query.trim() || siteResourceIds.length === 0) { return siteResourceIds; } const textMatch = combineOrConditions( - buildSearchConditionForSiteResource(query, labelsFeatureEnabled), + buildSearchConditionForSiteResource(query), buildSiteNameSearchCondition(query) ); if (!textMatch) { @@ -430,10 +409,6 @@ async function filterSiteResourceIdsByTextSearch( return rows.map((row) => row.siteResourceId); } -async function labelsEnabled(orgId: string): Promise { - return isLicensedOrSubscribed(orgId, tierMatrix.labels); -} - async function fetchLabelsForResources( orgId: string, resourceIds: number[], @@ -445,10 +420,6 @@ async function fetchLabelsForResources( const byResourceId = new Map(); const bySiteResourceId = new Map(); - if (!(await labelsEnabled(orgId))) { - return { byResourceId, bySiteResourceId }; - } - const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ resourceIds.length === 0 ? Promise.resolve([]) @@ -524,15 +495,8 @@ async function listSiteGroups( ): Promise<{ groups: LauncherGroup[]; total: number }> { const siteFilterIds = parseIdListParam(query.siteIds); const labelFilterIds = parseIdListParam(query.labelIds); - const labelsFeatureEnabled = await labelsEnabled(orgId); - const searchPublic = buildSearchConditionForPublic( - query.query, - labelsFeatureEnabled - ); - const searchSite = buildSearchConditionForSiteResource( - query.query, - labelsFeatureEnabled - ); + const searchPublic = buildSearchConditionForPublic(query.query); + const searchSite = buildSearchConditionForSiteResource(query.query); const siteCountMap = new Map(); if (accessible.resourceIds.length > 0) { @@ -775,10 +739,6 @@ async function listLabelGroups( >(); let unlabeledCount = 0; - if (!(await labelsEnabled(orgId))) { - return { groups: [], total: 0 }; - } - const matchesLabelFilters = (labelId: number) => labelFilterIds.length === 0 || labelFilterIds.includes(labelId); @@ -788,7 +748,7 @@ async function listLabelGroups( eq(resources.orgId, orgId), eq(resources.enabled, true) ]; - const searchPublic = buildSearchConditionForPublic(query.query, true); + const searchPublic = buildSearchConditionForPublic(query.query); if (searchPublic) { publicConditions.push(searchPublic); } @@ -852,10 +812,7 @@ async function listLabelGroups( eq(siteResources.orgId, orgId), eq(siteResources.enabled, true) ]; - const searchSite = buildSearchConditionForSiteResource( - query.query, - true - ); + const searchSite = buildSearchConditionForSiteResource(query.query); if (searchSite) { siteConditions.push(searchSite); } @@ -1302,23 +1259,19 @@ async function listLauncherResourcesForUserUncached( } } - const labelsFeatureEnabled = await labelsEnabled(orgId); - if (query.query.trim()) { if (filteredResourceIds.length > 0) { filteredResourceIds = await filterPublicResourceIdsByTextSearch( orgId, filteredResourceIds, - query.query, - labelsFeatureEnabled + query.query ); } if (filteredSiteResourceIds.length > 0) { filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch( orgId, filteredSiteResourceIds, - query.query, - labelsFeatureEnabled + query.query ); } } @@ -1518,10 +1471,6 @@ async function collectAccessibleLabels( ): Promise> { const labelMap = new Map(); - if (!(await labelsEnabled(orgId))) { - return labelMap; - } - if (accessible.resourceIds.length > 0) { const publicConditions = [ inArray(resources.resourceId, accessible.resourceIds), diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index c82dcac48..92951ee1d 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -363,11 +363,6 @@ export async function getUserResources( (r) => r.siteResourceId ); - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - let labelsForResources: Array<{ labelId: number; name: string; @@ -381,49 +376,45 @@ export async function getUserResources( siteResourceId: number; }> = []; - if (isLabelFeatureEnabled) { - [labelsForResources, labelsForSiteResources] = await Promise.all([ - resourceIdList.length === 0 - ? Promise.resolve([]) - : db - .select({ - labelId: labels.labelId, - name: labels.name, - color: labels.color, - resourceId: resourceLabels.resourceId - }) - .from(labels) - .innerJoin( - resourceLabels, - eq(resourceLabels.labelId, labels.labelId) + [labelsForResources, labelsForSiteResources] = await Promise.all([ + resourceIdList.length === 0 + ? Promise.resolve([]) + : db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + resourceId: resourceLabels.resourceId + }) + .from(labels) + .innerJoin( + resourceLabels, + eq(resourceLabels.labelId, labels.labelId) + ) + .where(inArray(resourceLabels.resourceId, resourceIdList)) + .orderBy(asc(resourceLabels.resourceLabelId)), + siteResourceIdList.length === 0 + ? Promise.resolve([]) + : db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + siteResourceId: siteResourceLabels.siteResourceId + }) + .from(labels) + .innerJoin( + siteResourceLabels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIdList ) - .where( - inArray(resourceLabels.resourceId, resourceIdList) - ) - .orderBy(asc(resourceLabels.resourceLabelId)), - siteResourceIdList.length === 0 - ? Promise.resolve([]) - : db - .select({ - labelId: labels.labelId, - name: labels.name, - color: labels.color, - siteResourceId: siteResourceLabels.siteResourceId - }) - .from(labels) - .innerJoin( - siteResourceLabels, - eq(siteResourceLabels.labelId, labels.labelId) - ) - .where( - inArray( - siteResourceLabels.siteResourceId, - siteResourceIdList - ) - ) - .orderBy(asc(siteResourceLabels.siteResourceLabelId)) - ]); - } + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)) + ]); // Check for password, pincode, and whitelist protection for each resource const resourcesWithAuth = await Promise.all( diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d1deb9d39..9567b5bcd 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -484,11 +484,6 @@ export async function listResources( ); } - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db @@ -676,7 +671,7 @@ export async function listResources( ); } - if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( resources.resourceId, @@ -697,25 +692,20 @@ export async function listResources( const queryList = [ like(sql`LOWER(${resources.name})`, q), like(sql`LOWER(${resources.niceId})`, q), - like(sql`LOWER(${resources.fullDomain})`, q) + like(sql`LOWER(${resources.fullDomain})`, q), + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) ]; - if (isLabelFeatureEnabled) { - queryList.push( - inArray( - resources.resourceId, - db - .select({ id: resourceLabels.resourceId }) - .from(resourceLabels) - .innerJoin( - labels, - eq(labels.labelId, resourceLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, q)) - ) - ); - } - conditions.push(or(...queryList)); } @@ -747,27 +737,23 @@ export async function listResources( resourceId: number; }> = []; - if (isLabelFeatureEnabled) { - labelsForResources = - resourceIdList.length === 0 - ? [] - : await db - .select({ - labelId: labels.labelId, - name: labels.name, - color: labels.color, - resourceId: resourceLabels.resourceId - }) - .from(labels) - .innerJoin( - resourceLabels, - eq(resourceLabels.labelId, labels.labelId) - ) - .where( - inArray(resourceLabels.resourceId, resourceIdList) - ) - .orderBy(asc(resourceLabels.resourceLabelId)); - } + labelsForResources = + resourceIdList.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + resourceId: resourceLabels.resourceId + }) + .from(labels) + .innerJoin( + resourceLabels, + eq(resourceLabels.labelId, labels.labelId) + ) + .where(inArray(resourceLabels.resourceId, resourceIdList)) + .orderBy(asc(resourceLabels.resourceLabelId)); const allResourceTargets = resourceIdList.length === 0 diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index dab8afc23..4ec87b8bb 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -248,11 +248,6 @@ export async function listUserResourceAliases( }); } - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - const whereConditions = [ eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), @@ -262,7 +257,7 @@ export async function listUserResourceAliases( inArray(siteResources.siteResourceId, accessibleSiteResourceIds) ]; - if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + if (labelFilter && labelFilter.length > 0) { whereConditions.push( inArray( siteResources.siteResourceId, @@ -310,7 +305,7 @@ export async function listUserResourceAliases( siteResourceId: number; }> = []; - if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + if (siteResourceIdList.length > 0) { labelsForSiteResources = await db .select({ name: labels.name, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 86c555f93..1f8e41f26 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -235,11 +235,6 @@ export async function listSites( ); } - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - const { pageSize, page, @@ -291,7 +286,7 @@ export async function listSites( conditions.push(eq(sites.status, status)); } - if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( sites.siteId, @@ -311,24 +306,20 @@ export async function listSites( const q = "%" + query.toLowerCase() + "%"; const queryList = [ like(sql`LOWER(${sites.name})`, q), - like(sql`LOWER(${sites.niceId})`, q) + like(sql`LOWER(${sites.niceId})`, q), + inArray( + sites.siteId, + db + .select({ id: siteLabels.siteId }) + .from(siteLabels) + .innerJoin( + labels, + eq(labels.labelId, siteLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) ]; - if (isLabelFeatureEnabled) { - queryList.push( - inArray( - sites.siteId, - db - .select({ id: siteLabels.siteId }) - .from(siteLabels) - .innerJoin( - labels, - eq(labels.labelId, siteLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, q)) - ) - ); - } conditions.push(or(...queryList)!); } @@ -366,25 +357,23 @@ export async function listSites( siteId: number; }> = []; - if (isLabelFeatureEnabled) { - labelsForSites = - siteIds.length === 0 - ? [] - : await db - .select({ - labelId: labels.labelId, - name: labels.name, - color: labels.color, - siteId: siteLabels.siteId - }) - .from(labels) - .innerJoin( - siteLabels, - eq(siteLabels.labelId, labels.labelId) - ) - .where(inArray(siteLabels.siteId, siteIds)) - .orderBy(asc(siteLabels.siteLabelId)); - } + labelsForSites = + siteIds.length === 0 + ? [] + : await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + siteId: siteLabels.siteId + }) + .from(labels) + .innerJoin( + siteLabels, + eq(siteLabels.labelId, labels.labelId) + ) + .where(inArray(siteLabels.siteId, siteIds)) + .orderBy(asc(siteLabels.siteLabelId)); const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 721d76bf6..4515e033e 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -286,11 +286,6 @@ export async function listAllSiteResourcesByOrg( labels: labelFilter } = parsedQuery.data; - const isLabelFeatureEnabled = await isLicensedOrSubscribed( - orgId, - tierMatrix.labels - ); - const conditions = [and(eq(siteResources.orgId, orgId))]; if (siteId != null) { @@ -320,7 +315,7 @@ export async function listAllSiteResourcesByOrg( conditions.push(eq(siteResources.mode, mode)); } - if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + if (labelFilter && labelFilter.length > 0) { conditions.push( inArray( siteResources.siteResourceId, @@ -344,25 +339,20 @@ export async function listAllSiteResourcesByOrg( like(sql`LOWER(${siteResources.destination})`, q), like(sql`LOWER(${siteResources.alias})`, q), like(sql`LOWER(${siteResources.aliasAddress})`, q), - like(sql`LOWER(${sites.name})`, q) + like(sql`LOWER(${sites.name})`, q), + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, q)) + ) ]; - if (isLabelFeatureEnabled) { - queryList.push( - inArray( - siteResources.siteResourceId, - db - .select({ id: siteResourceLabels.siteResourceId }) - .from(siteResourceLabels) - .innerJoin( - labels, - eq(labels.labelId, siteResourceLabels.labelId) - ) - .where(like(sql`LOWER(${labels.name})`, q)) - ) - ); - } - conditions.push(or(...queryList)); } @@ -403,7 +393,7 @@ export async function listAllSiteResourcesByOrg( siteResourceId: number; }> = []; - if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + if (siteResourceIdList.length > 0) { labelsForSiteResources = await db .select({ labelId: labels.labelId, diff --git a/src/app/[orgId]/settings/(private)/labels/page.tsx b/src/app/[orgId]/settings/labels/page.tsx similarity index 90% rename from src/app/[orgId]/settings/(private)/labels/page.tsx rename to src/app/[orgId]/settings/labels/page.tsx index f2ef0aa44..806c60325 100644 --- a/src/app/[orgId]/settings/(private)/labels/page.tsx +++ b/src/app/[orgId]/settings/labels/page.tsx @@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { AxiosResponse } from "axios"; import OrgLabelsTable from "@app/components/OrgLabelsTable"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) { description={t("orgLabelsDescription")} /> - - {t("address")} - } - ]; - - if (isLabelFeatureEnabled) { - baseColumns.push({ + }, + { id: "labels", accessorKey: "labels", header: () => ( @@ -458,8 +454,8 @@ export default function MachineClientsTable({ orgId={orgId} /> ) - }); - } + } + ]; // Only include actions column if there are rows without userIds if (hasRowsWithoutUserId) { @@ -541,7 +537,7 @@ export default function MachineClientsTable({ } return baseColumns; - }, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); + }, [hasRowsWithoutUserId, orgId, t, searchParams]); function handleFilterChange( column: string, diff --git a/src/components/OrgLabelsTable.tsx b/src/components/OrgLabelsTable.tsx index 3b2ea691b..1d936f274 100644 --- a/src/components/OrgLabelsTable.tsx +++ b/src/components/OrgLabelsTable.tsx @@ -21,9 +21,6 @@ import { ControlledDataTable, type ExtendedColumnDef } from "./ui/controlled-data-table"; -import { LabelBadge } from "./label-badge"; -import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; -import { cn } from "@app/lib/cn"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog"; import { EditOrgLabelDialog } from "./EditOrgLabelDialog"; diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx index ff854a1a8..9d0de1036 100644 --- a/src/components/PrivateResourcesTable.tsx +++ b/src/components/PrivateResourcesTable.tsx @@ -151,7 +151,6 @@ export default function PrivateResourcesTable({ const [isRefreshing, startRefreshTransition] = useTransition(); const { isPaidUser } = usePaidStatus(); - const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); // useEffect(() => { // const interval = setInterval(() => { @@ -476,11 +475,8 @@ export default function PrivateResourcesTable({
); } - } - ]; - - if (isLabelFeatureEnabled) { - cols.splice(cols.length - 1, 0, { + }, + { id: "labels", accessorKey: "labels", header: () => ( @@ -500,11 +496,11 @@ export default function PrivateResourcesTable({ orgId={orgId} /> ) - }); - } + } + ]; return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [orgId, t, searchParams]); function handleFilterChange( column: string, diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 1cc13ad80..2ac60013a 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -151,7 +151,6 @@ export default function PublicResourcesTable({ useState(); const { isPaidUser } = usePaidStatus(); - const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -604,11 +603,8 @@ export default function PublicResourcesTable({
); } - } - ]; - - if (isLabelFeatureEnabled) { - cols.splice(cols.length - 1, 0, { + }, + { id: "labels", accessorKey: "labels", header: () => ( @@ -625,11 +621,11 @@ export default function PublicResourcesTable({ cell: ({ row }: { row: { original: ResourceRow } }) => ( ) - }); - } + } + ]; return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [orgId, t, searchParams]); function handleFilterChange( column: string, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index efcf83e72..2592a2143 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -53,7 +53,6 @@ import { import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { useQuery } from "@tanstack/react-query"; @@ -114,7 +113,6 @@ export default function SitesTable({ const [isNavigatingToAddPage, startNavigation] = useTransition(); const { isPaidUser } = usePaidStatus(); - const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -171,7 +169,10 @@ export default function SitesTable({ toast({ variant: "destructive", title: t("siteErrorRestart"), - description: formatAxiosError(e, t("siteErrorRestartDescription")) + description: formatAxiosError( + e, + t("siteErrorRestartDescription") + ) }); } finally { setRestartingSite(null); @@ -598,11 +599,8 @@ export default function SitesTable({
); } - } - ]; - - if (isLabelFeatureEnabled) { - cols.splice(cols.length - 1, 0, { + }, + { accessorKey: "labels", header: () => ( ( ) - }); - } + } + ]; return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); + }, [orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From 0c3edc75602f5d4e1b48e285618b0ab66b36ae7b Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 7 Jul 2026 15:11:57 -0400 Subject: [PATCH 24/53] Further remove labels --- src/components/CreateOrgLabelDialog.tsx | 6 +----- src/components/EditOrgLabelDialog.tsx | 7 +------ 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/components/CreateOrgLabelDialog.tsx b/src/components/CreateOrgLabelDialog.tsx index dfd883522..cfa18e128 100644 --- a/src/components/CreateOrgLabelDialog.tsx +++ b/src/components/CreateOrgLabelDialog.tsx @@ -39,7 +39,6 @@ export function CreateOrgLabelDialog({ const t = useTranslations(); const api = createApiClient(useEnvContext()); const { isPaidUser } = usePaidStatus(); - const canManageLabels = isPaidUser(tierMatrix.labels); const [isSubmitting, startTransition] = useTransition(); async function createOrgLabel(data: { name: string; color: string }) { @@ -84,11 +83,8 @@ export function CreateOrgLabelDialog({ - { - if (!canManageLabels) return; startTransition(async () => createOrgLabel(data)); }} /> @@ -106,7 +102,7 @@ export function CreateOrgLabelDialog({ + ) : undefined + } /> {t("commandPaletteNoResults")} @@ -427,13 +446,14 @@ function isEditableTarget(target: EventTarget | null) { ); } -export function CommandPaletteProvider({ +function CommandPaletteProviderInner({ children, orgId, orgs, navItems }: CommandPaletteProviderProps) { const [open, setOpen] = useState(false); + const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs); const toggle = useCallback(() => { setOpen((current) => !current); @@ -449,6 +469,10 @@ export function CommandPaletteProvider({ ); useEffect(() => { + if (!canUseCommandPalette) { + return; + } + function onKeyDown(event: KeyboardEvent) { if ( event.key.toLowerCase() !== "k" || @@ -467,12 +491,18 @@ export function CommandPaletteProvider({ document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [open, toggle]); + }, [canUseCommandPalette, open, toggle]); return ( {children} - + {canUseCommandPalette ? ( + + ) : null} ); } + +export function CommandPaletteProvider(props: CommandPaletteProviderProps) { + return ; +} diff --git a/src/components/command-palette/CommandPaletteTrigger.tsx b/src/components/command-palette/CommandPaletteTrigger.tsx index 785013ae2..02913dda7 100644 --- a/src/components/command-palette/CommandPaletteTrigger.tsx +++ b/src/components/command-palette/CommandPaletteTrigger.tsx @@ -6,11 +6,15 @@ import { cn } from "@app/lib/cn"; import { Search } from "lucide-react"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; +import { ListUserOrgsResponse } from "@server/routers/org"; import { useCommandPalette } from "./CommandPalette"; +import { useCanUseCommandPalette } from "./useCanUseCommandPalette"; type CommandPaletteTriggerProps = { variant?: "header" | "mobile"; className?: string; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; }; function useIsMac() { @@ -25,11 +29,18 @@ function useIsMac() { export function CommandPaletteTrigger({ variant = "header", - className + className, + orgId, + orgs }: CommandPaletteTriggerProps) { const t = useTranslations(); const { setOpen } = useCommandPalette(); const isMac = useIsMac(); + const canUseCommandPalette = useCanUseCommandPalette(orgId, orgs); + + if (!canUseCommandPalette) { + return null; + } if (variant === "mobile") { return ( @@ -49,13 +60,13 @@ export function CommandPaletteTrigger({