From 1717a99cee573a60f02a5ebf1eb4145c5024855a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 21:53:52 -0400 Subject: [PATCH 1/9] add compact mode for large orgs --- messages/en-US.json | 7 + server/routers/external.ts | 6 + server/routers/launcher/index.ts | 1 + .../launcher/launcherResourceAccess.ts | 11 +- server/routers/launcher/launcherScale.ts | 124 +++++++++++++++++ server/routers/launcher/listLauncherScale.ts | 60 +++++++++ server/routers/launcher/types.ts | 34 ++++- src/app/[orgId]/page.tsx | 1 + src/components/multi-site-selector.tsx | 14 +- .../LauncherFilterPopover.tsx | 4 + .../LauncherFlatResourceList.tsx | 125 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 24 ++-- .../LauncherSearchFirstGate.tsx | 86 ++++++++++++ .../LauncherSettingsMenu.tsx | 47 ++++++- .../resource-launcher/ResourceLauncher.tsx | 82 ++++++++++-- src/lib/launcherLocalStorage.ts | 8 +- src/lib/launcherScale.ts | 95 +++++++++++++ src/lib/launcherServerData.ts | 54 ++++++-- src/lib/launcherUrlState.ts | 2 +- src/lib/queries.ts | 15 +++ 20 files changed, 757 insertions(+), 43 deletions(-) create mode 100644 server/routers/launcher/launcherScale.ts create mode 100644 server/routers/launcher/listLauncherScale.ts create mode 100644 src/components/resource-launcher/LauncherFlatResourceList.tsx create mode 100644 src/components/resource-launcher/LauncherSearchFirstGate.tsx create mode 100644 src/lib/launcherScale.ts diff --git a/messages/en-US.json b/messages/en-US.json index 0129c1159..e699aba9f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3590,6 +3590,7 @@ "resourceLauncherGroupBy": "Group By", "resourceLauncherGroupBySite": "Site", "resourceLauncherGroupByLabel": "Label", + "resourceLauncherGroupByNone": "None", "resourceLauncherLayout": "Layout", "resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutList": "List", @@ -3607,6 +3608,12 @@ "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", + "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", + "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", + "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", + "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", + "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/routers/external.ts b/server/routers/external.ts index 5d770c064..43cf88a43 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -470,6 +470,12 @@ authenticated.get( launcher.listLauncherGroups ); +authenticated.get( + "/org/:orgId/launcher/scale", + verifyOrgAccess, + launcher.listLauncherScale +); + authenticated.get( "/org/:orgId/launcher/resources", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index 939bf74bc..e941bbe10 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -1,5 +1,6 @@ export * from "./types"; export { listLauncherGroups } from "./listLauncherGroups"; +export { listLauncherScale } from "./listLauncherScale"; export { listLauncherResources } from "./listLauncherResources"; export { listLauncherSites } from "./listLauncherSites"; export { listLauncherLabels } from "./listLauncherLabels"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 8fe5e8ef8..2ce6d87d4 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -38,6 +38,7 @@ import { formatSiteResourceAccess } from "./formatLauncherAccess"; import { + LAUNCHER_FLAT_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, type LauncherFilterListQuery, @@ -1161,10 +1162,12 @@ export async function listLauncherResourcesForUser( let items = [...publicItems, ...siteItems]; items = filterResourcesBySearch(items, query.query); - if (query.groupBy === "label") { - items = filterResourcesByLabel(items, query.groupKey); - } else if (query.groupBy === "site") { - items = filterResourcesBySite(items, query.groupKey); + if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { + if (query.groupBy === "label") { + items = filterResourcesByLabel(items, query.groupKey); + } else if (query.groupBy === "site") { + items = filterResourcesBySite(items, query.groupKey); + } } items = sortLauncherResources(items, query.order); diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts new file mode 100644 index 000000000..ec7750cfb --- /dev/null +++ b/server/routers/launcher/launcherScale.ts @@ -0,0 +1,124 @@ +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { + listLauncherGroupsForUser, + resolveAccessibleIds +} from "./launcherResourceAccess"; +import { + parseIdListParam, + type LauncherScaleInfo, + type LauncherScaleQuery +} from "./types"; + +export const LAUNCHER_FULL_MAX_RESOURCES = 2000; +export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200; +export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100; +export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20; + +const LAUNCHER_SCALE_TTL_SEC = 60; + +function launcherScaleCacheKey( + orgId: string, + userId: string, + roleIds: number[], + query: LauncherScaleQuery +) { + const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); + const filterKey = [ + query.query, + query.groupBy, + query.siteIds ?? "", + query.labelIds ?? "", + query.sort_by, + query.order + ].join("|"); + return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`; +} + +async function getLauncherScaleForUserUncached( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherScaleQuery +): Promise { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const resourceCount = + accessible.resourceIds.length + accessible.siteResourceIds.length; + + const baselineQuery = { + query: "", + groupBy: "site" as const, + siteIds: undefined, + labelIds: undefined, + sort_by: "name" as const, + order: "asc" as const, + page: 1, + pageSize: 1 + }; + + const [{ total: siteGroupCount }, { total: labelGroupCount }] = + await Promise.all([ + listLauncherGroupsForUser( + orgId, + userId, + userRoleIds, + baselineQuery + ), + listLauncherGroupsForUser(orgId, userId, userRoleIds, { + ...baselineQuery, + groupBy: "label" + }) + ]); + + const mode = + resourceCount <= LAUNCHER_FULL_MAX_RESOURCES && + siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS + ? "full" + : "compact"; + + const siteFilterIds = parseIdListParam(query.siteIds); + + const allowSiteGrouping = + siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || + (siteFilterIds.length > 0 && + siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); + + const allowLabelGrouping = + labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS; + + const requireSearchOrFilter = + mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; + + return { + mode, + resourceCount, + siteGroupCount, + labelGroupCount, + capabilities: { + allowSiteGrouping, + allowLabelGrouping, + requireSearchOrFilter + } + }; +} + +export async function getLauncherScaleForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherScaleQuery +): Promise { + const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + + const result = await getLauncherScaleForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC); + return result; +} diff --git a/server/routers/launcher/listLauncherScale.ts b/server/routers/launcher/listLauncherScale.ts new file mode 100644 index 000000000..2fc907b17 --- /dev/null +++ b/server/routers/launcher/listLauncherScale.ts @@ -0,0 +1,60 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { getLauncherScaleForUser } from "./launcherScale"; +import { launcherScaleQuerySchema } from "./types"; + +export async function listLauncherScale( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherScaleQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const scale = await getLauncherScaleForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { scale }, + success: true, + error: false, + message: "Launcher scale retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher scale:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 0a252d74d..2b7ee7f6e 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -2,9 +2,10 @@ import { z } from "zod"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; +export const LAUNCHER_FLAT_GROUP_KEY = "__all__"; export const launcherViewConfigSchema = z.object({ - groupBy: z.enum(["site", "label"]).default("site"), + groupBy: z.enum(["site", "label", "none"]).default("site"), layout: z.enum(["grid", "list"]).default("grid"), sortBy: z.literal("name").default("name"), order: z.enum(["asc", "desc"]).default("asc"), @@ -138,7 +139,7 @@ export const launcherListQuerySchema = z.strictObject({ .default(20), page: z.coerce.number().int().min(1).optional().catch(1).default(1), query: z.string().optional().default(""), - groupBy: z.enum(["site", "label"]).optional().default("site"), + groupBy: z.enum(["site", "label", "none"]).optional().default("site"), groupKey: z.string().optional(), siteIds: z.string().optional(), labelIds: z.string().optional(), @@ -163,3 +164,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; + +export type LauncherScaleCapabilities = { + allowSiteGrouping: boolean; + allowLabelGrouping: boolean; + requireSearchOrFilter: boolean; +}; + +export type LauncherScaleInfo = { + mode: "full" | "compact"; + resourceCount: number; + siteGroupCount: number; + labelGroupCount: number; + capabilities: LauncherScaleCapabilities; +}; + +export type ListLauncherScaleResponse = { + scale: LauncherScaleInfo; +}; + +export const launcherScaleQuerySchema = z.strictObject({ + query: z.string().optional().default(""), + groupBy: z.enum(["site", "label", "none"]).optional().default("site"), + siteIds: z.string().optional(), + labelIds: z.string().optional(), + sort_by: z.literal("name").optional().default("name"), + order: z.enum(["asc", "desc"]).optional().default("asc") +}); + +export type LauncherScaleQuery = z.infer; diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 7abb1b317..0197a5502 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -85,6 +85,7 @@ export default async function OrgPage(props: OrgPageProps) { activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} + scale={launcherData.scale} groups={launcherData.groups} groupsPagination={launcherData.groupsPagination} /> diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index fe81dc697..15db6ceea 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -20,6 +20,8 @@ export type MultiSitesSelectorProps = { onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; scope?: "org" | "launcher"; + onClear?: () => void; + showClear?: boolean; }; export function formatMultiSitesSelectorLabel( @@ -42,7 +44,9 @@ export function MultiSitesSelector({ selectedSites, onSelectionChange, filterTypes, - scope = "org" + scope = "org", + onClear, + showClear = false }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); @@ -107,6 +111,14 @@ export function MultiSitesSelector({ {t("siteNotFound")} + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} {sitesShown.map((site) => ( 0} + onClear={() => { + onSitesChange([]); + }} /> diff --git a/src/components/resource-launcher/LauncherFlatResourceList.tsx b/src/components/resource-launcher/LauncherFlatResourceList.tsx new file mode 100644 index 000000000..7bbc01ccb --- /dev/null +++ b/src/components/resource-launcher/LauncherFlatResourceList.tsx @@ -0,0 +1,125 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { hasActiveLauncherFilters } from "@app/lib/launcherScale"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherResource, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { LAUNCHER_FLAT_GROUP_KEY } from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; +import { LauncherResourceGrid } from "./LauncherResourceGrid"; +import { LauncherResourceList } from "./LauncherResourceList"; + +type LauncherFlatResourceListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + onClearFilters?: () => void; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherFlatResourceList({ + orgId, + config, + onClearFilters, + onResourceSelect +}: LauncherFlatResourceListProps) { + const loadMoreRef = useRef(null); + + const resourceFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + groupKey: LAUNCHER_FLAT_GROUP_KEY, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + [ + config.groupBy, + config.labelIds, + config.order, + config.query, + config.siteIds, + config.sortBy + ] + ); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, resourceFilters) + }); + + const resources = data?.pages.flatMap((page) => page.resources) ?? []; + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + if (resources.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + + return ( +
+ {config.layout === "grid" ? ( + + ) : ( + + )} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index c7fe6b905..ecf60a773 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -69,16 +69,20 @@ export function LauncherGroupList({ const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery({ ...launcherQueries.groups(orgId, groupFilters), - initialData: { - pages: [ - { - groups: initialGroups, - pagination: groupsPagination - } - ], - pageParams: [1] - }, - refetchOnMount: false + ...(initialGroups.length > 0 + ? { + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false as const + } + : {}) }); const groups = data?.pages.flatMap((page) => page.groups) ?? []; diff --git a/src/components/resource-launcher/LauncherSearchFirstGate.tsx b/src/components/resource-launcher/LauncherSearchFirstGate.tsx new file mode 100644 index 000000000..ab9ea1226 --- /dev/null +++ b/src/components/resource-launcher/LauncherSearchFirstGate.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type LauncherSearchFirstGateProps = { + layout: "grid" | "list"; +}; + +function GhostResourceGrid() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function GhostResourceList() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function LauncherSearchFirstGate({ + layout +}: LauncherSearchFirstGateProps) { + const t = useTranslations(); + + return ( +
+
+ {layout === "grid" ? ( + + ) : ( + + )} +
+
+
+ +
+
+

+ {t("resourceLauncherSearchFirstTitle")} +

+

+ {t("resourceLauncherSearchFirstDescription")} +

+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 41143cd10..abce2e5e6 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -15,13 +15,19 @@ import { SelectValue } from "@app/components/ui/select"; import { Switch } from "@app/components/ui/switch"; -import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import type { + LauncherScaleCapabilities, + LauncherViewConfig +} from "@server/routers/launcher/types"; import { useTranslations } from "next-intl"; import { Settings } from "lucide-react"; type LauncherSettingsMenuProps = { config: LauncherViewConfig; isDefaultView: boolean; + capabilities: LauncherScaleCapabilities; + isCompactMode: boolean; + selectedGroupBy: LauncherViewConfig["groupBy"]; onConfigChange: (patch: Partial) => void; onDeleteView: () => void; }; @@ -29,6 +35,9 @@ type LauncherSettingsMenuProps = { export function LauncherSettingsMenu({ config, isDefaultView, + capabilities, + isCompactMode, + selectedGroupBy, onConfigChange, onDeleteView }: LauncherSettingsMenuProps) { @@ -51,7 +60,7 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupBy")}

+ {isCompactMode ? ( +

+ {t("resourceLauncherCompactGroupingLocked")} +

+ ) : null} + {!isCompactMode && !capabilities.allowSiteGrouping ? ( +

+ {t("resourceLauncherSiteGroupingDisabled")} +

+ ) : null} + {!isCompactMode && !capabilities.allowLabelGrouping ? ( +

+ {t("resourceLauncherLabelGroupingDisabled")} +

+ ) : null}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index be5620d25..2cc9d50a2 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -29,12 +29,20 @@ import { } from "@app/lib/launcherUrlState"; import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + getEffectiveLauncherConfig, + shouldShowFlatResourceList, + shouldShowLauncherGroupList, + shouldShowSearchFirstGate +} from "@app/lib/launcherScale"; +import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherScaleInfo, LauncherViewConfig, LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -52,7 +60,9 @@ import type { SelectedLabel } from "@app/components/labels-selector"; import { useMediaQuery } from "@app/hooks/useMediaQuery"; import { cn } from "@app/lib/cn"; import { LauncherFilterPopover } from "./LauncherFilterPopover"; +import { LauncherFlatResourceList } from "./LauncherFlatResourceList"; import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherSearchFirstGate } from "./LauncherSearchFirstGate"; import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; @@ -66,6 +76,7 @@ type ResourceLauncherProps = { activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; + scale: LauncherScaleInfo; groups: LauncherGroup[]; groupsPagination: { total: number; @@ -81,6 +92,7 @@ export default function ResourceLauncher({ activeViewId, config, savedConfig, + scale: initialScale, groups, groupsPagination }: ResourceLauncherProps) { @@ -100,6 +112,38 @@ export default function ResourceLauncher({ const isDesktop = useMediaQuery("(min-width: 768px)"); + const scaleFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order + }), + [ + config.groupBy, + config.labelIds, + config.order, + config.query, + config.siteIds, + config.sortBy + ] + ); + + const { data: scale = initialScale } = useQuery({ + ...launcherQueries.scale(orgId, scaleFilters), + initialData: initialScale + }); + + const showGroupList = shouldShowLauncherGroupList(scale, config); + const showSearchFirstGate = shouldShowSearchFirstGate(scale, config); + const showFlatResourceList = shouldShowFlatResourceList(scale, config); + const effectiveConfig = useMemo( + () => getEffectiveLauncherConfig(scale, config), + [config, scale] + ); + const configRef = useRef(config); configRef.current = config; const searchInputRef = useRef(config.query); @@ -451,6 +495,9 @@ export default function ResourceLauncher({ { if (!isDefaultView) { @@ -502,14 +549,31 @@ export default function ResourceLauncher({
)} - + {scale.mode === "compact" && !showSearchFirstGate ? ( +

+ {t("resourceLauncherCompactModeHint")} +

+ ) : null} + + {showSearchFirstGate ? ( + + ) : showGroupList ? ( + + ) : showFlatResourceList ? ( + + ) : null} diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts index 6e1fb60a5..1d2b64219 100644 --- a/src/lib/launcherLocalStorage.ts +++ b/src/lib/launcherLocalStorage.ts @@ -10,7 +10,7 @@ function lastViewKey(orgId: string) { function groupOpenKey( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label" + groupBy: "site" | "label" | "none" ) { return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; } @@ -64,7 +64,7 @@ export function writeLauncherLastView( export function readLauncherGroupOpenState( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label" + groupBy: "site" | "label" | "none" ): Record { return readJson>( groupOpenKey(orgId, viewId, groupBy), @@ -75,7 +75,7 @@ export function readLauncherGroupOpenState( export function readLauncherGroupOpen( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label", + groupBy: "site" | "label" | "none", groupKey: string, defaultOpen: boolean ): boolean { @@ -86,7 +86,7 @@ export function readLauncherGroupOpen( export function writeLauncherGroupOpen( orgId: string, viewId: LauncherActiveViewId, - groupBy: "site" | "label", + groupBy: "site" | "label" | "none", groupKey: string, isOpen: boolean ) { diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts new file mode 100644 index 000000000..6b086bab9 --- /dev/null +++ b/src/lib/launcherScale.ts @@ -0,0 +1,95 @@ +import type { + LauncherScaleInfo, + LauncherViewConfig +} from "@server/routers/launcher/types"; + +export function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { + return ( + config.query.trim().length > 0 || + config.siteIds.length > 0 || + config.labelIds.length > 0 + ); +} + +export function getEffectiveGroupBy( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): LauncherViewConfig["groupBy"] { + if (scale.mode === "compact") { + return "none"; + } + + return config.groupBy; +} + +export function getEffectiveLauncherConfig( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): LauncherViewConfig { + const groupBy = getEffectiveGroupBy(scale, config); + if (groupBy === config.groupBy) { + return config; + } + + return { ...config, groupBy }; +} + +export function shouldFetchLauncherGroups( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + const groupBy = getEffectiveGroupBy(scale, config); + + if (groupBy === "none") { + return false; + } + + if (scale.mode === "full") { + return true; + } + + return ( + scale.capabilities.allowSiteGrouping && + groupBy === "site" && + config.siteIds.length > 0 + ); +} + +export function shouldShowLauncherGroupList( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + return shouldFetchLauncherGroups(scale, config); +} + +export function shouldShowSearchFirstGate( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + return ( + scale.mode === "compact" && + scale.capabilities.requireSearchOrFilter && + !hasActiveLauncherFilters(config) + ); +} + +export function shouldShowFlatResourceList( + scale: LauncherScaleInfo, + config: LauncherViewConfig +): boolean { + if (shouldShowSearchFirstGate(scale, config)) { + return false; + } + + const groupBy = getEffectiveGroupBy(scale, config); + + if (groupBy === "none") { + return true; + } + + if (scale.mode === "full") { + return false; + } + + return !shouldShowLauncherGroupList(scale, config); +} diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index 2c542c5fc..994ca2ff4 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -1,12 +1,15 @@ import { internal } from "@app/lib/api"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { shouldFetchLauncherGroups } from "@app/lib/launcherScale"; import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, + LauncherScaleInfo, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, + ListLauncherScaleResponse, ListLauncherViewsResponse } from "@server/routers/launcher/types"; import { AxiosResponse } from "axios"; @@ -16,6 +19,7 @@ export type LauncherPageData = { activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; + scale: LauncherScaleInfo; groups: LauncherGroup[]; groupsPagination: { total: number; @@ -45,6 +49,37 @@ export async function fetchLauncherPageData( null ); + const scaleFilters = { + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order + }; + + let scale: LauncherScaleInfo = { + mode: "full", + resourceCount: 0, + siteGroupCount: 0, + labelGroupCount: 0, + capabilities: { + allowSiteGrouping: true, + allowLabelGrouping: true, + requireSearchOrFilter: false + } + }; + + try { + const sp = buildLauncherSearchParams(scaleFilters, 1); + sp.delete("page"); + sp.delete("pageSize"); + const scaleRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/scale?${sp.toString()}`, cookieHeader); + scale = scaleRes.data.data.scale; + } catch (e) {} + const groupFilters = { query: config.query, groupBy: config.groupBy, @@ -62,20 +97,23 @@ export async function fetchLauncherPageData( pageSize: 20 }; - try { - const sp = buildLauncherSearchParams(groupFilters, 1); - const groupsRes = await internal.get< - AxiosResponse - >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader); - groups = groupsRes.data.data.groups; - groupsPagination = groupsRes.data.data.pagination; - } catch (e) {} + if (shouldFetchLauncherGroups(scale, config)) { + try { + const sp = buildLauncherSearchParams(groupFilters, 1); + const groupsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader); + groups = groupsRes.data.data.groups; + groupsPagination = groupsRes.data.data.pagination; + } catch (e) {} + } return { views, activeViewId, config, savedConfig, + scale, groups, groupsPagination }; diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts index cfe8e26c4..c3b2da568 100644 --- a/src/lib/launcherUrlState.ts +++ b/src/lib/launcherUrlState.ts @@ -113,7 +113,7 @@ function parseConfigOverrides( } const groupBy = searchParams.get("groupBy"); - if (groupBy === "site" || groupBy === "label") { + if (groupBy === "site" || groupBy === "label" || groupBy === "none") { overrides.groupBy = groupBy; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 14fa1d3da..5ca6624d6 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -50,6 +50,7 @@ import type { ListLauncherGroupsResponse, ListLauncherLabelsResponse, ListLauncherResourcesResponse, + ListLauncherScaleResponse, ListLauncherSitesResponse, ListLauncherViewsResponse, LauncherListQuery, @@ -1298,5 +1299,19 @@ export const launcherQueries = { const nextPage = page + 1; return page * pageSize < total ? nextPage : undefined; } + }), + scale: (orgId: string, filters: LauncherQueryFilters) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "SCALE", filters] as const, + queryFn: async ({ signal, meta }) => { + const sp = buildLauncherSearchParams(filters, 1); + sp.delete("page"); + sp.delete("pageSize"); + sp.delete("groupKey"); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/scale?${sp.toString()}`, { signal }); + return res.data.data.scale; + } }) }; From 12f9ac94fd63ca02c7cdfcd09a40abbfdf1f2b3d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 22:07:30 -0400 Subject: [PATCH 2/9] allow grouping when filter applied --- messages/en-US.json | 2 +- server/routers/launcher/launcherScale.ts | 5 ++- .../LauncherSettingsMenu.tsx | 12 ++++--- .../resource-launcher/ResourceLauncher.tsx | 6 ---- src/lib/launcherScale.ts | 31 +++++++++++++++---- 5 files changed, 37 insertions(+), 19 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e699aba9f..fede28c11 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3613,7 +3613,7 @@ "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", - "resourceLauncherCompactGroupingLocked": "Grouping is set to None in compact mode.", + "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts index ec7750cfb..5d9eb42c2 100644 --- a/server/routers/launcher/launcherScale.ts +++ b/server/routers/launcher/launcherScale.ts @@ -76,6 +76,7 @@ async function getLauncherScaleForUserUncached( : "compact"; const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); const allowSiteGrouping = siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || @@ -83,7 +84,9 @@ async function getLauncherScaleForUserUncached( siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); const allowLabelGrouping = - labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS; + labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || + (labelFilterIds.length > 0 && + labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); const requireSearchOrFilter = mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index abce2e5e6..2cf2a3cbb 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -78,8 +78,9 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupBySite")} @@ -87,8 +88,9 @@ export function LauncherSettingsMenu({ {t("resourceLauncherGroupByLabel")} @@ -97,7 +99,7 @@ export function LauncherSettingsMenu({ {isCompactMode ? (

- {t("resourceLauncherCompactGroupingLocked")} + {t("resourceLauncherCompactGroupingHint")}

) : null} {!isCompactMode && !capabilities.allowSiteGrouping ? ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 2cc9d50a2..23db498ea 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -549,12 +549,6 @@ export default function ResourceLauncher({
)} - {scale.mode === "compact" && !showSearchFirstGate ? ( -

- {t("resourceLauncherCompactModeHint")} -

- ) : null} - {showSearchFirstGate ? ( ) : showGroupList ? ( diff --git a/src/lib/launcherScale.ts b/src/lib/launcherScale.ts index 6b086bab9..a9e03ecb4 100644 --- a/src/lib/launcherScale.ts +++ b/src/lib/launcherScale.ts @@ -15,11 +15,27 @@ export function getEffectiveGroupBy( scale: LauncherScaleInfo, config: LauncherViewConfig ): LauncherViewConfig["groupBy"] { - if (scale.mode === "compact") { - return "none"; + if (scale.mode !== "compact") { + return config.groupBy; } - return config.groupBy; + if ( + config.groupBy === "site" && + config.siteIds.length > 0 && + scale.capabilities.allowSiteGrouping + ) { + return "site"; + } + + if ( + config.groupBy === "label" && + config.labelIds.length > 0 && + scale.capabilities.allowLabelGrouping + ) { + return "label"; + } + + return "none"; } export function getEffectiveLauncherConfig( @@ -49,9 +65,12 @@ export function shouldFetchLauncherGroups( } return ( - scale.capabilities.allowSiteGrouping && - groupBy === "site" && - config.siteIds.length > 0 + (scale.capabilities.allowSiteGrouping && + groupBy === "site" && + config.siteIds.length > 0) || + (scale.capabilities.allowLabelGrouping && + groupBy === "label" && + config.labelIds.length > 0) ); } From f0546eb6224eb17a15a1a1bbb1a12d093bbea1dd Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 22:27:51 -0400 Subject: [PATCH 3/9] add save default view --- messages/en-US.json | 13 +- server/routers/external.ts | 12 + server/routers/launcher/createLauncherView.ts | 10 + .../launcher/deleteLauncherDefaultView.ts | 104 +++++++++ server/routers/launcher/deleteLauncherView.ts | 10 + server/routers/launcher/index.ts | 2 + .../routers/launcher/launcherDefaultView.ts | 144 ++++++++++++ server/routers/launcher/listLauncherViews.ts | 23 +- server/routers/launcher/types.ts | 22 ++ server/routers/launcher/updateLauncherView.ts | 10 + .../launcher/upsertLauncherDefaultView.ts | 82 +++++++ src/app/[orgId]/page.tsx | 1 + .../LauncherSettingsMenu.tsx | 16 +- .../resource-launcher/LauncherViewTabs.tsx | 41 +++- .../resource-launcher/ResourceLauncher.tsx | 211 +++++++++++++++--- src/lib/launcherServerData.ts | 11 +- src/lib/launcherUrlState.ts | 26 ++- src/lib/queries.ts | 2 +- 18 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 server/routers/launcher/deleteLauncherDefaultView.ts create mode 100644 server/routers/launcher/launcherDefaultView.ts create mode 100644 server/routers/launcher/upsertLauncherDefaultView.ts diff --git a/messages/en-US.json b/messages/en-US.json index fede28c11..a4e99d5c5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3571,12 +3571,16 @@ "memberPortalResourceDisabled": "Resource Disabled", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "resourceLauncherTitle": "Resource Launcher", - "resourceLauncherDescription": "View resource details and launch them from one place", - "resourceLauncherSearchPlaceholder": "Search all sites...", + "resourceLauncherDescription": "View all available resources and launch them from one central hub", + "resourceLauncherSearchPlaceholder": "Search your resources...", "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveDefaultPersonal": "Save for Me", "resourceLauncherResetView": "Reset View", + "resourceLauncherResetSystemDefault": "Reset to System Default", + "resourceLauncherSystemDefaultRestored": "System default restored", + "resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", @@ -3598,6 +3602,9 @@ "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", + "resourceLauncherDeleteViewTitle": "Delete View", + "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", + "resourceLauncherDeleteViewConfirm": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", @@ -3609,7 +3616,7 @@ "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", "resourceLauncherSearchFirstTitle": "Search or Filter to Browse", - "resourceLauncherSearchFirstDescription": "This organization has many resources. Use search or filter by site or label to find what you need.", + "resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.", "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", diff --git a/server/routers/external.ts b/server/routers/external.ts index 43cf88a43..2b95c2bfc 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -512,6 +512,18 @@ authenticated.put( launcher.updateLauncherView ); +authenticated.put( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.upsertLauncherDefaultView +); + +authenticated.delete( + "/org/:orgId/launcher/default-view", + verifyOrgAccess, + launcher.deleteLauncherDefaultView +); + authenticated.delete( "/org/:orgId/launcher/views/:viewId", verifyOrgAccess, diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 593bad304..4c77cd4f0 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -7,6 +7,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -40,6 +41,15 @@ export async function createLauncherView( ); } + if (isLauncherDefaultOverrideViewName(parsed.data.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This view name is reserved" + ) + ); + } + if (parsed.data.orgWide) { const canCreateOrgWide = await checkUserActionPermission( ActionsEnum.createOrgWideLauncherView, diff --git a/server/routers/launcher/deleteLauncherDefaultView.ts b/server/routers/launcher/deleteLauncherDefaultView.ts new file mode 100644 index 000000000..50a1a7beb --- /dev/null +++ b/server/routers/launcher/deleteLauncherDefaultView.ts @@ -0,0 +1,104 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAllDefaultViewOverrides, + deleteDefaultViewOverride +} from "./launcherDefaultView"; + +const deleteLauncherDefaultViewBodySchema = z.strictObject({ + orgWide: z.boolean().optional().default(false), + all: z.boolean().optional().default(false) +}); + +export async function deleteLauncherDefaultView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.all) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteAllDefaultViewOverrides(orgId, userId); + } else if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: true + }); + } else { + await deleteDefaultViewOverride({ + orgId, + userId, + orgWide: false + }); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher default view reset successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error resetting launcher default view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index beaf1b433..41d7dd214 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -6,6 +6,7 @@ import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; export async function deleteLauncherView( req: Request, @@ -46,6 +47,15 @@ export async function deleteLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The default view cannot be deleted from here" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index e941bbe10..b9edafc22 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -8,3 +8,5 @@ export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; +export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView"; +export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView"; diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts new file mode 100644 index 000000000..4d180bd1d --- /dev/null +++ b/server/routers/launcher/launcherDefaultView.ts @@ -0,0 +1,144 @@ +import { db, launcherViews } from "@server/db"; +import { and, eq, isNull } from "drizzle-orm"; +import moment from "moment"; +import { + LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + launcherViewConfigSchema, + type LauncherDefaultViewOverrides, + type LauncherViewConfig, + type LauncherViewRecord +} from "./types"; + +export function isLauncherDefaultOverrideViewName(name: string) { + return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME; +} + +export function mapViewRow( + row: typeof launcherViews.$inferSelect +): LauncherViewRecord { + return { + viewId: row.viewId, + orgId: row.orgId, + userId: row.userId, + name: row.name, + config: launcherViewConfigSchema.parse(JSON.parse(row.config)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isOrgWide: row.userId == null + }; +} + +export function extractDefaultViewOverrides( + rows: Array +): LauncherDefaultViewOverrides { + const overrideRows = rows.filter((row) => + isLauncherDefaultOverrideViewName(row.name) + ); + + const personalRow = overrideRows.find((row) => row.userId !== null); + const orgWideRow = overrideRows.find((row) => row.userId === null); + + return { + personal: personalRow ? mapViewRow(personalRow) : null, + orgWide: orgWideRow ? mapViewRow(orgWideRow) : null + }; +} + +export function listVisibleLauncherViews( + rows: Array +): LauncherViewRecord[] { + return rows + .filter((row) => !isLauncherDefaultOverrideViewName(row.name)) + .map(mapViewRow); +} + +export async function findDefaultViewOverride( + orgId: string, + orgWide: boolean, + userId: string +) { + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME), + orgWide + ? isNull(launcherViews.userId) + : eq(launcherViews.userId, userId) + ) + ) + .limit(1); + + return existing ?? null; +} + +export async function upsertDefaultViewOverride({ + orgId, + userId, + orgWide, + config +}: { + orgId: string; + userId: string; + orgWide: boolean; + config: LauncherViewConfig; +}) { + const now = moment().toISOString(); + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + + if (existing) { + const [updated] = await db + .update(launcherViews) + .set({ + config: JSON.stringify(config), + updatedAt: now + }) + .where(eq(launcherViews.viewId, existing.viewId)) + .returning(); + + return mapViewRow(updated); + } + + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: orgWide ? null : userId, + name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + config: JSON.stringify(config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return mapViewRow(created); +} + +export async function deleteDefaultViewOverride({ + orgId, + userId, + orgWide +}: { + orgId: string; + userId: string; + orgWide: boolean; +}) { + const existing = await findDefaultViewOverride(orgId, orgWide, userId); + if (!existing) { + return; + } + + await db + .delete(launcherViews) + .where(eq(launcherViews.viewId, existing.viewId)); +} + +export async function deleteAllDefaultViewOverrides( + orgId: string, + userId: string +) { + await deleteDefaultViewOverride({ orgId, userId, orgWide: false }); + await deleteDefaultViewOverride({ orgId, userId, orgWide: true }); +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index e176bae46..8cdcf76ef 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode"; import { and, eq, isNull, or } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; - -function mapViewRow( - row: typeof launcherViews.$inferSelect -): LauncherViewRecord { - return { - viewId: row.viewId, - orgId: row.orgId, - userId: row.userId, - name: row.name, - config: launcherViewConfigSchema.parse(JSON.parse(row.config)), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - isOrgWide: row.userId == null - }; -} +import { + extractDefaultViewOverrides, + listVisibleLauncherViews +} from "./launcherDefaultView"; export async function listLauncherViews( req: Request, @@ -51,7 +39,8 @@ export async function listLauncherViews( return response(res, { data: { - views: rows.map(mapViewRow) + views: listVisibleLauncherViews(rows), + defaultViewOverrides: extractDefaultViewOverrides(rows) }, success: true, error: false, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 2b7ee7f6e..9f9701c96 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -91,8 +91,28 @@ export type LauncherViewRecord = { isOrgWide: boolean; }; +export type LauncherDefaultViewOverrides = { + personal: LauncherViewRecord | null; + orgWide: LauncherViewRecord | null; +}; + +export function getEffectiveDefaultLauncherConfig( + overrides: LauncherDefaultViewOverrides +): LauncherViewConfig { + if (overrides.personal) { + return overrides.personal.config; + } + + if (overrides.orgWide) { + return overrides.orgWide.config; + } + + return defaultLauncherViewConfig; +} + export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; }; export const launcherFilterListQuerySchema = z.strictObject({ @@ -161,6 +181,8 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; +export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__"; + export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 9450da153..757a16dac 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -9,6 +9,7 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -66,6 +67,15 @@ export async function updateLauncherView( ); } + if (isLauncherDefaultOverrideViewName(existing.name)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Use the default view save endpoint for this view" + ) + ); + } + const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; const canManageOrgWide = await checkUserActionPermission( diff --git a/server/routers/launcher/upsertLauncherDefaultView.ts b/server/routers/launcher/upsertLauncherDefaultView.ts new file mode 100644 index 000000000..dd9c8bdba --- /dev/null +++ b/server/routers/launcher/upsertLauncherDefaultView.ts @@ -0,0 +1,82 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { upsertDefaultViewOverride } from "./launcherDefaultView"; +import { launcherViewConfigSchema } from "./types"; + +const upsertLauncherDefaultViewBodySchema = z.strictObject({ + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function upsertLauncherDefaultView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + if (parsed.data.orgWide) { + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + const view = await upsertDefaultViewOverride({ + orgId, + userId, + orgWide: parsed.data.orgWide, + config: parsed.data.config + }); + + return response(res, { + data: view, + success: true, + error: false, + message: "Launcher default view saved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error saving launcher default view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 0197a5502..d543b2752 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -82,6 +82,7 @@ export default async function OrgPage(props: OrgPageProps) { orgId={orgId} isAdmin={isAdminOrOwner} views={launcherData.views} + defaultViewOverrides={launcherData.defaultViewOverrides} activeViewId={launcherData.activeViewId} config={launcherData.config} savedConfig={launcherData.savedConfig} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 2cf2a3cbb..fc10d4a7f 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -24,22 +24,18 @@ import { Settings } from "lucide-react"; type LauncherSettingsMenuProps = { config: LauncherViewConfig; - isDefaultView: boolean; capabilities: LauncherScaleCapabilities; isCompactMode: boolean; selectedGroupBy: LauncherViewConfig["groupBy"]; onConfigChange: (patch: Partial) => void; - onDeleteView: () => void; }; export function LauncherSettingsMenu({ config, - isDefaultView, capabilities, isCompactMode, selectedGroupBy, - onConfigChange, - onDeleteView + onConfigChange }: LauncherSettingsMenuProps) { const t = useTranslations(); @@ -157,16 +153,6 @@ export function LauncherSettingsMenu({ />
- - {!isDefaultView ? ( - - ) : null}
diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx index 98d8f7289..0b0261560 100644 --- a/src/components/resource-launcher/LauncherViewTabs.tsx +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -68,11 +68,14 @@ type LauncherSaveViewMenuProps = { isAdmin: boolean; isOrgWideView: boolean; hasUnsavedChanges: boolean; + canResetSystemDefault: boolean; onSaveToCurrent: () => void; onSaveAsNew: () => void; onSaveForEveryone: () => void; onMakePersonal: () => void; onResetView: () => void; + onResetSystemDefault: () => void; + onDeleteView: () => void; }; export function LauncherSaveViewMenu({ @@ -80,13 +83,17 @@ export function LauncherSaveViewMenu({ isAdmin, isOrgWideView, hasUnsavedChanges, + canResetSystemDefault, onSaveToCurrent, onSaveAsNew, onSaveForEveryone, onMakePersonal, - onResetView + onResetView, + onResetSystemDefault, + onDeleteView }: LauncherSaveViewMenuProps) { const t = useTranslations(); + const canDeleteView = !isDefaultView && (isAdmin || !isOrgWideView); return ( @@ -108,7 +115,26 @@ export function LauncherSaveViewMenu({ ) : null} - {!isDefaultView && (isAdmin || !isOrgWideView) ? ( + {isDefaultView && canResetSystemDefault ? ( + <> + + {t("resourceLauncherResetSystemDefault")} + + + + ) : null} + {isDefaultView && hasUnsavedChanges ? ( + <> + + {t("resourceLauncherSaveDefaultPersonal")} + + {isAdmin ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + + ) : !isDefaultView && (isAdmin || !isOrgWideView) ? ( {t("resourceLauncherSaveToCurrentView")} @@ -126,6 +152,17 @@ export function LauncherSaveViewMenu({ {t("resourceLauncherMakePersonal")} ) : null} + {canDeleteView ? ( + <> + + + {t("resourceLauncherDeleteView")} + + + ) : null} ); diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 23db498ea..9f9d44d11 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -36,11 +36,13 @@ import { shouldShowSearchFirstGate } from "@app/lib/launcherScale"; import { launcherQueries } from "@app/lib/queries"; -import type { - LauncherGroup, - LauncherScaleInfo, - LauncherViewConfig, - LauncherViewRecord +import { + getEffectiveDefaultLauncherConfig, + type LauncherDefaultViewOverrides, + type LauncherGroup, + type LauncherScaleInfo, + type LauncherViewConfig, + type LauncherViewRecord } from "@server/routers/launcher/types"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Search } from "lucide-react"; @@ -67,12 +69,14 @@ import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourceLauncherProps = { orgId: string; isAdmin: boolean; views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; @@ -89,6 +93,7 @@ export default function ResourceLauncher({ orgId, isAdmin, views, + defaultViewOverrides, activeViewId, config, savedConfig, @@ -107,6 +112,7 @@ export default function ResourceLauncher({ const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -173,13 +179,24 @@ export default function ResourceLauncher({ return; } - const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const baseConfig = getLauncherUrlBaseConfig( + lastView, + views, + defaultViewOverrides + ); const params = serializeLauncherUrlState({ viewId: lastView, config: baseConfig }); navigate({ searchParams: params, replace: true }); - }, [activeViewId, navigate, orgId, searchParams, views]); + }, [ + activeViewId, + defaultViewOverrides, + navigate, + orgId, + searchParams, + views + ]); const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { @@ -202,10 +219,14 @@ export default function ResourceLauncher({ const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); - const baseConfig = getLauncherUrlBaseConfig(viewId, views); + const baseConfig = getLauncherUrlBaseConfig( + viewId, + views, + defaultViewOverrides + ); navigateToConfig(viewId, baseConfig); }, - [navigateToConfig, orgId, views] + [defaultViewOverrides, navigateToConfig, orgId, views] ); const activeSavedView = useMemo( @@ -219,6 +240,10 @@ export default function ResourceLauncher({ const isDefaultView = activeViewId === "default"; const isOrgWideView = Boolean(activeSavedView?.isOrgWide); const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); + const canResetSystemDefault = + isDefaultView && + (Boolean(defaultViewOverrides.personal) || + (isAdmin && Boolean(defaultViewOverrides.orgWide))); const selectedSites: Selectedsite[] = useMemo( () => @@ -314,6 +339,87 @@ export default function ResourceLauncher({ } }); + const saveDefaultViewMutation = useMutation({ + mutationFn: async (payload: { + config: LauncherViewConfig; + orgWide: boolean; + }) => { + const res = await api.put( + `/org/${orgId}/launcher/default-view`, + payload + ); + return res.data.data as LauncherViewRecord; + }, + onSuccess: () => { + writeLauncherLastView(orgId, "default"); + const params = serializeLauncherUrlState({ + viewId: "default", + config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const resetSystemDefaultMutation = useMutation({ + mutationFn: async () => { + const resetAll = isAdmin && Boolean(defaultViewOverrides.orgWide); + await api.delete(`/org/${orgId}/launcher/default-view`, { + data: resetAll ? { all: true } : { orgWide: false } + }); + }, + onSuccess: () => { + writeLauncherLastView(orgId, "default"); + const nextOverrides: LauncherDefaultViewOverrides = { + personal: null, + orgWide: + isAdmin && defaultViewOverrides.orgWide + ? null + : defaultViewOverrides.orgWide + }; + const targetConfig = + getEffectiveDefaultLauncherConfig(nextOverrides); + searchInputRef.current = targetConfig.query; + setSearchInputResetKey((key) => key + 1); + const params = serializeLauncherUrlState({ + viewId: "default", + config: targetConfig + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherSystemDefaultRestored"), + description: t( + "resourceLauncherSystemDefaultRestoredDescription" + ) + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + const deleteViewMutation = useMutation({ mutationFn: async (viewId: number) => { await api.delete(`/org/${orgId}/launcher/views/${viewId}`); @@ -322,7 +428,11 @@ export default function ResourceLauncher({ writeLauncherLastView(orgId, "default"); const params = serializeLauncherUrlState({ viewId: "default", - config: getLauncherUrlBaseConfig("default", views) + config: getLauncherUrlBaseConfig( + "default", + views, + defaultViewOverrides + ) }); navigate({ searchParams: params, replace: true }); router.refresh(); @@ -372,6 +482,10 @@ export default function ResourceLauncher({ navigateToConfig(activeViewIdRef.current, savedConfig); }, [navigateToConfig, savedConfig]); + const handleResetSystemDefault = useCallback(() => { + resetSystemDefaultMutation.mutate(); + }, [resetSystemDefaultMutation]); + const refreshData = () => { startRefreshTransition(async () => { try { @@ -387,7 +501,14 @@ export default function ResourceLauncher({ }; const handleSaveToCurrent = () => { - if (isDefaultView || (isOrgWideView && !isAdmin)) { + if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: false + }); + return; + } + if (isOrgWideView && !isAdmin) { return; } updateViewMutation.mutate({ @@ -404,6 +525,10 @@ export default function ResourceLauncher({ const handleSaveForEveryone = () => { if (isDefaultView) { + saveDefaultViewMutation.mutate({ + config, + orgWide: true + }); return; } updateViewMutation.mutate({ @@ -456,19 +581,8 @@ export default function ResourceLauncher({
); - const renderToolbarActions = () => ( + const renderToolbarFilterSort = () => ( <> - + + ); + + const renderToolbarActions = () => ( + <> + setDeleteDialogOpen(true)} + /> { - if (!isDefaultView) { - deleteViewMutation.mutate(activeViewId); - } - }} /> {renderToolbarSearch("w-64")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -542,7 +672,12 @@ export default function ResourceLauncher({
{renderToolbarActions()}
- {renderToolbarSearch("w-full")} +
+
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarFilterSort()} +
{renderToolbarViews()}
@@ -569,6 +704,22 @@ export default function ResourceLauncher({ /> ) : null} + {activeSavedView ? ( + {t("resourceLauncherDeleteViewQuestion")}

} + onConfirm={async () => { + await deleteViewMutation.mutateAsync( + activeSavedView.viewId + ); + }} + /> + ) : null} + diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts index 994ca2ff4..8abb0d2bc 100644 --- a/src/lib/launcherServerData.ts +++ b/src/lib/launcherServerData.ts @@ -6,6 +6,7 @@ import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; import type { LauncherGroup, LauncherScaleInfo, + LauncherDefaultViewOverrides, LauncherViewConfig, LauncherViewRecord, ListLauncherGroupsResponse, @@ -16,6 +17,7 @@ import { AxiosResponse } from "axios"; export type LauncherPageData = { views: LauncherViewRecord[]; + defaultViewOverrides: LauncherDefaultViewOverrides; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; savedConfig: LauncherViewConfig; @@ -36,17 +38,23 @@ export async function fetchLauncherPageData( > ): Promise { let views: LauncherViewRecord[] = []; + let defaultViewOverrides: LauncherDefaultViewOverrides = { + personal: null, + orgWide: null + }; try { const viewsRes = await internal.get< AxiosResponse >(`/org/${orgId}/launcher/views`, cookieHeader); views = viewsRes.data.data.views; + defaultViewOverrides = viewsRes.data.data.defaultViewOverrides; } catch (e) {} const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( searchParams, views, - null + null, + defaultViewOverrides ); const scaleFilters = { @@ -110,6 +118,7 @@ export async function fetchLauncherPageData( return { views, + defaultViewOverrides, activeViewId, config, savedConfig, diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts index c3b2da568..dff904568 100644 --- a/src/lib/launcherUrlState.ts +++ b/src/lib/launcherUrlState.ts @@ -1,7 +1,9 @@ import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; import { defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig, parseIdListParam, + type LauncherDefaultViewOverrides, type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; @@ -64,10 +66,13 @@ export function isLauncherConfigEqual( export function getLauncherUrlBaseConfig( viewId: LauncherActiveViewId, - views: LauncherViewRecord[] + views: LauncherViewRecord[], + defaultViewOverrides?: LauncherDefaultViewOverrides ): LauncherViewConfig { if (viewId === "default") { - return defaultLauncherViewConfig; + return defaultViewOverrides + ? getEffectiveDefaultLauncherConfig(defaultViewOverrides) + : defaultLauncherViewConfig; } const savedView = views.find((view) => view.viewId === viewId); @@ -172,7 +177,8 @@ function isValidActiveViewId( export function resolveLauncherStateFromUrl( searchParams: URLSearchParams, views: LauncherViewRecord[], - fallbackViewId: LauncherActiveViewId | null + fallbackViewId: LauncherActiveViewId | null, + defaultViewOverrides?: LauncherDefaultViewOverrides ): ResolvedLauncherState { const parsed = parseLauncherUrlState(searchParams); @@ -188,18 +194,26 @@ export function resolveLauncherStateFromUrl( : "default"; } - const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + const savedConfig = getLauncherUrlBaseConfig( + activeViewId, + views, + defaultViewOverrides + ); let config: LauncherViewConfig; if (hasLauncherConfigParams(searchParams)) { config = resolveLauncherConfig( - defaultLauncherViewConfig, + getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ), parsed.configOverrides ); } else if (activeViewId !== "default") { config = savedConfig; } else { - config = defaultLauncherViewConfig; + config = getEffectiveDefaultLauncherConfig( + defaultViewOverrides ?? { personal: null, orgWide: null } + ); } return { diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 5ca6624d6..2626a3ba3 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1190,7 +1190,7 @@ export const launcherQueries = { const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/views`, { signal }); - return res.data.data.views; + return res.data.data; } }), sites: ({ From a89509c8bd09034527ec790de58b5094f83543fc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 2 Jul 2026 23:32:16 -0400 Subject: [PATCH 4/9] add more caching --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 291 +++++++++++++++--- server/routers/launcher/launcherScale.ts | 108 ++++--- .../LauncherFilterPopover.tsx | 23 +- 4 files changed, 334 insertions(+), 89 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index a4e99d5c5..45393e184 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3587,6 +3587,7 @@ "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", "resourceLauncherMakePersonal": "Make Personal", "resourceLauncherFilter": "Filter", + "resourceLauncherFilterWithCount": "Filter, {count} applied", "resourceLauncherSort": "Sort", "resourceLauncherSortAscending": "Sort ascending", "resourceLauncherSortDescending": "Sort descending", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 2ce6d87d4..246f86688 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { db } from "@server/db"; import { exitNodes, @@ -31,7 +32,8 @@ import { isNull, like, or, - sql + sql, + type SQL } from "drizzle-orm"; import { formatPublicResourceAccess, @@ -60,6 +62,68 @@ export type AccessibleIds = { }; const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; +const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60; +const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60; + +type LauncherResourcesCacheEntry = { + items: LauncherResource[]; + total: number; +}; + +type LauncherGroupsCacheEntry = { + groups: LauncherGroup[]; + total: number; +}; + +function launcherListQueryHash( + userRoleIds: number[], + query: LauncherListQuery, + extra?: Record +) { + const payload = JSON.stringify({ + roles: [...userRoleIds].sort((a, b) => a - b), + query: query.query, + groupBy: query.groupBy, + siteIds: query.siteIds ?? "", + labelIds: query.labelIds ?? "", + sort_by: query.sort_by, + order: query.order, + ...extra + }); + return createHash("sha256").update(payload).digest("hex").slice(0, 16); +} + +function launcherResourcesQueryHash( + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +) { + return launcherListQueryHash(userRoleIds, query, { + groupKey: query.groupKey + }); +} + +function launcherGroupsQueryHash( + userRoleIds: number[], + query: LauncherListQuery +) { + return launcherListQueryHash(userRoleIds, query); +} + +function launcherResourcesCacheKey( + orgId: string, + userId: string, + queryHash: string +) { + return `launcher:results:${orgId}:${userId}:${queryHash}`; +} + +function launcherGroupsCacheKey( + orgId: string, + userId: string, + queryHash: string +) { + return `launcher:groups:${orgId}:${userId}:${queryHash}`; +} function launcherAccessibleIdsCacheKey( orgId: string, @@ -195,6 +259,21 @@ function searchPattern(query: string) { return `%${query.trim()}%`; } +function combineOrConditions( + ...conditions: (SQL | undefined)[] +): SQL | undefined { + const parts = conditions.filter( + (condition): condition is SQL => !!condition + ); + if (parts.length === 0) { + return undefined; + } + if (parts.length === 1) { + return parts[0]; + } + return or(...parts); +} + function buildSearchConditionForPublic( query: string, labelsFeatureEnabled: boolean @@ -206,7 +285,17 @@ function buildSearchConditionForPublic( const queryList = [ like(sql`LOWER(${resources.name})`, pattern), like(sql`LOWER(${resources.fullDomain})`, pattern), - like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) + like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern), + inArray( + resources.resourceId, + db + .select({ id: resources.resourceId }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) + .where(like(sql`LOWER(${exitNodes.endpoint})`, pattern)) + ) ]; if (labelsFeatureEnabled) { @@ -239,6 +328,11 @@ function buildSearchConditionForSiteResource( const queryList = [ like(sql`LOWER(${siteResources.name})`, pattern), like(sql`LOWER(${siteResources.destination})`, pattern), + like( + sql`LOWER(cast(${siteResources.destinationPort} as text))`, + pattern + ), + like(sql`LOWER(${siteResources.scheme})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern), like(sql`LOWER(${siteResources.aliasAddress})`, pattern) @@ -263,6 +357,79 @@ function buildSearchConditionForSiteResource( return or(...queryList); } +async function filterPublicResourceIdsByTextSearch( + orgId: string, + resourceIds: number[], + query: string, + labelsFeatureEnabled: boolean +): Promise { + if (!query.trim() || resourceIds.length === 0) { + return resourceIds; + } + + const textMatch = combineOrConditions( + buildSearchConditionForPublic(query, labelsFeatureEnabled), + buildSiteNameSearchCondition(query) + ); + if (!textMatch) { + return resourceIds; + } + + const rows = await db + .selectDistinct({ resourceId: resources.resourceId }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and( + inArray(resources.resourceId, resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + textMatch + ) + ); + + return rows.map((row) => row.resourceId); +} + +async function filterSiteResourceIdsByTextSearch( + orgId: string, + siteResourceIds: number[], + query: string, + labelsFeatureEnabled: boolean +): Promise { + if (!query.trim() || siteResourceIds.length === 0) { + return siteResourceIds; + } + + const textMatch = combineOrConditions( + buildSearchConditionForSiteResource(query, labelsFeatureEnabled), + buildSiteNameSearchCondition(query) + ); + if (!textMatch) { + return siteResourceIds; + } + + const rows = await db + .selectDistinct({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + inArray(siteResources.siteResourceId, siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + textMatch + ) + ); + + return rows.map((row) => row.siteResourceId); +} + async function labelsEnabled(orgId: string): Promise { return isLicensedOrSubscribed(orgId, tierMatrix.labels); } @@ -589,9 +756,8 @@ async function listSiteGroups( }); const total = groups.length; - const offset = (query.page - 1) * query.pageSize; return { - groups: groups.slice(offset, offset + query.pageSize), + groups, total }; } @@ -789,26 +955,53 @@ async function listLabelGroups( }); const total = groups.length; - const offset = (query.page - 1) * query.pageSize; return { - groups: groups.slice(offset, offset + query.pageSize), + groups, total }; } +async function listLauncherGroupsForUserUncached( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery +): Promise { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + if (query.groupBy === "label") { + return listLabelGroups(orgId, accessible, query); + } + + return listSiteGroups(orgId, accessible, query); +} + export async function listLauncherGroupsForUser( orgId: string, userId: string, userRoleIds: number[], query: LauncherListQuery ): Promise<{ groups: LauncherGroup[]; total: number }> { - const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const queryHash = launcherGroupsQueryHash(userRoleIds, query); + const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash); + const cached = await cache.get(cacheKey); - if (query.groupBy === "label") { - return listLabelGroups(orgId, accessible, query); + let result = cached; + if (!result) { + result = await listLauncherGroupsForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC); } - return listSiteGroups(orgId, accessible, query); + const offset = (query.page - 1) * query.pageSize; + return { + groups: result.groups.slice(offset, offset + query.pageSize), + total: result.total + }; } async function mapPublicResources( @@ -1019,26 +1212,6 @@ function filterResourcesByLabel( ); } -function filterResourcesBySearch( - items: LauncherResource[], - query: string -): LauncherResource[] { - if (!query.trim()) { - return items; - } - const pattern = query.trim().toLowerCase(); - return items.filter( - (item) => - item.name.toLowerCase().includes(pattern) || - item.accessDisplay.toLowerCase().includes(pattern) || - item.accessCopyValue.toLowerCase().includes(pattern) || - item.labels.some((label) => - label.name.toLowerCase().includes(pattern) - ) || - item.site?.name.toLowerCase().includes(pattern) - ); -} - function sortLauncherResources( items: LauncherResource[], order: "asc" | "desc" @@ -1051,12 +1224,12 @@ function sortLauncherResources( }); } -export async function listLauncherResourcesForUser( +async function listLauncherResourcesForUserUncached( orgId: string, userId: string, userRoleIds: number[], query: LauncherListQuery & { groupKey: string } -): Promise<{ resources: LauncherResource[]; total: number }> { +): Promise { const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const siteFilterIds = parseIdListParam(query.siteIds); @@ -1129,6 +1302,27 @@ export async function listLauncherResourcesForUser( } } + const labelsFeatureEnabled = await labelsEnabled(orgId); + + if (query.query.trim()) { + if (filteredResourceIds.length > 0) { + filteredResourceIds = await filterPublicResourceIdsByTextSearch( + orgId, + filteredResourceIds, + query.query, + labelsFeatureEnabled + ); + } + if (filteredSiteResourceIds.length > 0) { + filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch( + orgId, + filteredSiteResourceIds, + query.query, + labelsFeatureEnabled + ); + } + } + const labelMaps = await fetchLabelsForResources( orgId, filteredResourceIds, @@ -1160,7 +1354,6 @@ export async function listLauncherResourcesForUser( ]); let items = [...publicItems, ...siteItems]; - items = filterResourcesBySearch(items, query.query); if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) { if (query.groupBy === "label") { @@ -1172,11 +1365,37 @@ export async function listLauncherResourcesForUser( items = sortLauncherResources(items, query.order); - const total = items.length; + return { + items, + total: items.length + }; +} + +export async function listLauncherResourcesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + const queryHash = launcherResourcesQueryHash(userRoleIds, query); + const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash); + const cached = await cache.get(cacheKey); + + let result = cached; + if (!result) { + result = await listLauncherResourcesForUserUncached( + orgId, + userId, + userRoleIds, + query + ); + await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC); + } + const offset = (query.page - 1) * query.pageSize; return { - resources: items.slice(offset, offset + query.pageSize), - total + resources: result.items.slice(offset, offset + query.pageSize), + total: result.total }; } diff --git a/server/routers/launcher/launcherScale.ts b/server/routers/launcher/launcherScale.ts index 5d9eb42c2..8d512834b 100644 --- a/server/routers/launcher/launcherScale.ts +++ b/server/routers/launcher/launcherScale.ts @@ -14,32 +14,57 @@ export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200; export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100; export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20; -const LAUNCHER_SCALE_TTL_SEC = 60; +const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60; -function launcherScaleCacheKey( +type LauncherScaleCountsCacheEntry = { + resourceCount: number; + siteGroupCount: number; + labelGroupCount: number; + mode: LauncherScaleInfo["mode"]; +}; + +function launcherScaleCountsCacheKey( orgId: string, userId: string, - roleIds: number[], - query: LauncherScaleQuery + roleIds: number[] ) { const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); - const filterKey = [ - query.query, - query.groupBy, - query.siteIds ?? "", - query.labelIds ?? "", - query.sort_by, - query.order - ].join("|"); - return `launcherScale:${orgId}:${userId}:${rolesKey}:${filterKey}`; + return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`; } -async function getLauncherScaleForUserUncached( +function buildScaleCapabilities( + counts: LauncherScaleCountsCacheEntry, + query: LauncherScaleQuery +): LauncherScaleInfo["capabilities"] { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + + return { + allowSiteGrouping: + counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || + (siteFilterIds.length > 0 && + siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX), + allowLabelGrouping: + counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || + (labelFilterIds.length > 0 && + labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX), + requireSearchOrFilter: + counts.mode === "compact" && + counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES + }; +} + +async function getLauncherScaleCountsForUser( orgId: string, userId: string, - userRoleIds: number[], - query: LauncherScaleQuery -): Promise { + userRoleIds: number[] +): Promise { + const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const resourceCount = accessible.resourceIds.length + accessible.siteResourceIds.length; @@ -75,33 +100,15 @@ async function getLauncherScaleForUserUncached( ? "full" : "compact"; - const siteFilterIds = parseIdListParam(query.siteIds); - const labelFilterIds = parseIdListParam(query.labelIds); - - const allowSiteGrouping = - siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS || - (siteFilterIds.length > 0 && - siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const allowLabelGrouping = - labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS || - (labelFilterIds.length > 0 && - labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX); - - const requireSearchOrFilter = - mode === "compact" && resourceCount > LAUNCHER_FULL_MAX_RESOURCES; - - return { - mode, + const result: LauncherScaleCountsCacheEntry = { resourceCount, siteGroupCount, labelGroupCount, - capabilities: { - allowSiteGrouping, - allowLabelGrouping, - requireSearchOrFilter - } + mode }; + + await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC); + return result; } export async function getLauncherScaleForUser( @@ -110,18 +117,17 @@ export async function getLauncherScaleForUser( userRoleIds: number[], query: LauncherScaleQuery ): Promise { - const cacheKey = launcherScaleCacheKey(orgId, userId, userRoleIds, query); - const cached = await cache.get(cacheKey); - if (cached) { - return cached; - } - - const result = await getLauncherScaleForUserUncached( + const counts = await getLauncherScaleCountsForUser( orgId, userId, - userRoleIds, - query + userRoleIds ); - await cache.set(cacheKey, result, LAUNCHER_SCALE_TTL_SEC); - return result; + + return { + mode: counts.mode, + resourceCount: counts.resourceCount, + siteGroupCount: counts.siteGroupCount, + labelGroupCount: counts.labelGroupCount, + capabilities: buildScaleCapabilities(counts, query) + }; } diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 9306a752c..8f44b1139 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -10,6 +10,7 @@ import { type SelectedLabel } from "@app/components/labels-selector"; import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { Popover, @@ -96,14 +97,32 @@ export function LauncherFilterPopover({ [labels, selectedLabels] ); + const activeFilterCount = selectedSites.length + selectedLabels.length; + return ( - From b53f80d31711f4f35d9519e908675e095df3c20f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 09:59:49 -0400 Subject: [PATCH 5/9] use column for default view --- messages/en-US.json | 2 +- server/db/pg/schema/schema.ts | 1 + server/db/sqlite/schema/schema.ts | 3 +++ server/routers/launcher/createLauncherView.ts | 13 ++---------- server/routers/launcher/deleteLauncherView.ts | 3 +-- .../routers/launcher/launcherDefaultView.ts | 21 +++++++------------ server/routers/launcher/types.ts | 3 +-- server/routers/launcher/updateLauncherView.ts | 6 +++--- 8 files changed, 19 insertions(+), 33 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 45393e184..90be25679 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3621,7 +3621,7 @@ "resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.", "resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.", "resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.", - "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping in compact mode.", + "resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 683c572b6..5375e4fc2 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", { }), name: varchar("name").notNull(), config: text("config").notNull(), + isDefault: boolean("isDefault").notNull().default(false), createdAt: varchar("createdAt").notNull(), updatedAt: varchar("updatedAt").notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 28797bcd9..5f0c2f9a7 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", { }), name: text("name").notNull(), config: text("config").notNull(), + isDefault: integer("isDefault", { mode: "boolean" }) + .notNull() + .default(false), createdAt: text("createdAt").notNull(), updatedAt: text("updatedAt").notNull() }); diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index 4c77cd4f0..195dfc555 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -7,7 +7,6 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -41,15 +40,6 @@ export async function createLauncherView( ); } - if (isLauncherDefaultOverrideViewName(parsed.data.name)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "This view name is reserved" - ) - ); - } - if (parsed.data.orgWide) { const canCreateOrgWide = await checkUserActionPermission( ActionsEnum.createOrgWideLauncherView, @@ -89,7 +79,8 @@ export async function createLauncherView( ), createdAt: created.createdAt, updatedAt: created.updatedAt, - isOrgWide: created.userId == null + isOrgWide: created.userId == null, + isDefault: created.isDefault }, success: true, error: false, diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index 41d7dd214..8377872fb 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -6,7 +6,6 @@ import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; export async function deleteLauncherView( req: Request, @@ -47,7 +46,7 @@ export async function deleteLauncherView( ); } - if (isLauncherDefaultOverrideViewName(existing.name)) { + if (existing.isDefault) { return next( createHttpError( HttpCode.BAD_REQUEST, diff --git a/server/routers/launcher/launcherDefaultView.ts b/server/routers/launcher/launcherDefaultView.ts index 4d180bd1d..a0c2fd066 100644 --- a/server/routers/launcher/launcherDefaultView.ts +++ b/server/routers/launcher/launcherDefaultView.ts @@ -2,17 +2,12 @@ import { db, launcherViews } from "@server/db"; import { and, eq, isNull } from "drizzle-orm"; import moment from "moment"; import { - LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, launcherViewConfigSchema, type LauncherDefaultViewOverrides, type LauncherViewConfig, type LauncherViewRecord } from "./types"; -export function isLauncherDefaultOverrideViewName(name: string) { - return name === LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME; -} - export function mapViewRow( row: typeof launcherViews.$inferSelect ): LauncherViewRecord { @@ -24,16 +19,15 @@ export function mapViewRow( config: launcherViewConfigSchema.parse(JSON.parse(row.config)), createdAt: row.createdAt, updatedAt: row.updatedAt, - isOrgWide: row.userId == null + isOrgWide: row.userId == null, + isDefault: row.isDefault }; } export function extractDefaultViewOverrides( rows: Array ): LauncherDefaultViewOverrides { - const overrideRows = rows.filter((row) => - isLauncherDefaultOverrideViewName(row.name) - ); + const overrideRows = rows.filter((row) => row.isDefault); const personalRow = overrideRows.find((row) => row.userId !== null); const orgWideRow = overrideRows.find((row) => row.userId === null); @@ -47,9 +41,7 @@ export function extractDefaultViewOverrides( export function listVisibleLauncherViews( rows: Array ): LauncherViewRecord[] { - return rows - .filter((row) => !isLauncherDefaultOverrideViewName(row.name)) - .map(mapViewRow); + return rows.filter((row) => !row.isDefault).map(mapViewRow); } export async function findDefaultViewOverride( @@ -63,7 +55,7 @@ export async function findDefaultViewOverride( .where( and( eq(launcherViews.orgId, orgId), - eq(launcherViews.name, LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME), + eq(launcherViews.isDefault, true), orgWide ? isNull(launcherViews.userId) : eq(launcherViews.userId, userId) @@ -106,7 +98,8 @@ export async function upsertDefaultViewOverride({ .values({ orgId, userId: orgWide ? null : userId, - name: LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME, + name: "", + isDefault: true, config: JSON.stringify(config), createdAt: now, updatedAt: now diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 9f9701c96..4a986118c 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -89,6 +89,7 @@ export type LauncherViewRecord = { createdAt: string; updatedAt: string; isOrgWide: boolean; + isDefault: boolean; }; export type LauncherDefaultViewOverrides = { @@ -181,8 +182,6 @@ export function parseIdListParam(value: string | undefined): number[] { export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; -export const LAUNCHER_DEFAULT_OVERRIDE_VIEW_NAME = "__default__"; - export type LauncherViewSelection = | { type: "default" } | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 757a16dac..e4373151f 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -9,7 +9,6 @@ import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; -import { isLauncherDefaultOverrideViewName } from "./launcherDefaultView"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -67,7 +66,7 @@ export async function updateLauncherView( ); } - if (isLauncherDefaultOverrideViewName(existing.name)) { + if (existing.isDefault) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -145,7 +144,8 @@ export async function updateLauncherView( ), createdAt: updated.createdAt, updatedAt: updated.updatedAt, - isOrgWide: updated.userId == null + isOrgWide: updated.userId == null, + isDefault: updated.isDefault }, success: true, error: false, From 811119a9a6ec3b496f2562032feaed9a94beb4bc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 10:13:58 -0400 Subject: [PATCH 6/9] reduce filter dropdown page size --- server/routers/launcher/types.ts | 4 ++-- src/components/LabelsFilterSelector.tsx | 20 ++++++++++++++++--- src/components/multi-site-selector.tsx | 2 +- .../LauncherFilterPopover.tsx | 5 +++-- src/lib/queries.ts | 4 ++-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 4a986118c..f235e824d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -122,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({ .int() .positive() .optional() - .catch(500) - .default(500), + .catch(20) + .default(20), page: z.coerce.number().int().min(1).optional().catch(1).default(1), query: z.string().optional().default("") }); diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx index 3c9410e43..84a1819d8 100644 --- a/src/components/LabelsFilterSelector.tsx +++ b/src/components/LabelsFilterSelector.tsx @@ -11,7 +11,7 @@ import { import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useDebounce } from "use-debounce"; import { Checkbox } from "./ui/checkbox"; @@ -25,6 +25,7 @@ type LabelsFilterSelectorProps = { orgId: string; isSelected: (label: LabelFilterOption) => boolean; onToggle: (label: LabelFilterOption) => void; + selectedLabels?: LabelFilterOption[]; onClear?: () => void; showClear?: boolean; scope?: "org" | "launcher"; @@ -34,6 +35,7 @@ export function LabelsFilterSelector({ orgId, isSelected, onToggle, + selectedLabels = [], onClear, showClear = false, scope = "org" @@ -54,7 +56,7 @@ export function LabelsFilterSelector({ ...launcherQueries.labels({ orgId, query: debouncedQuery, - perPage: 500 + perPage: 20 }), enabled: scope === "launcher" }); @@ -63,6 +65,18 @@ export function LabelsFilterSelector({ ? (launcherLabelsQuery.data ?? []) : (orgLabelsQuery.data ?? []); + const labelsShown = useMemo(() => { + const base = [...labels]; + if (debouncedQuery.trim().length === 0 && selectedLabels.length > 0) { + const selectedNotInBase = selectedLabels.filter( + (selected) => + !base.some((label) => label.labelId === selected.labelId) + ); + return [...selectedNotInBase, ...base]; + } + return base; + }, [debouncedQuery, labels, selectedLabels]); + return ( )} - {labels.map((label) => ( + {labelsShown.map((label) => ( selectedLabelIds.has(label.labelId) } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 2626a3ba3..211ab7824 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1196,7 +1196,7 @@ export const launcherQueries = { sites: ({ orgId, query, - perPage = 500 + perPage = 20 }: { orgId: string; query?: string; @@ -1228,7 +1228,7 @@ export const launcherQueries = { labels: ({ orgId, query, - perPage = 500 + perPage = 20 }: { orgId: string; query?: string; From 9edb86fd73d95beb869009779f89953567ed987b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 10:30:41 -0400 Subject: [PATCH 7/9] add invalidate launcher cache on refresh --- server/routers/external.ts | 6 ++ server/routers/launcher/index.ts | 1 + .../launcher/invalidateLauncherCache.ts | 65 +++++++++++++++++++ .../resource-launcher/ResourceLauncher.tsx | 7 +- 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 server/routers/launcher/invalidateLauncherCache.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 2b95c2bfc..33fcb3ab3 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -500,6 +500,12 @@ authenticated.get( launcher.listLauncherViews ); +authenticated.post( + "/org/:orgId/launcher/invalidate-cache", + verifyOrgAccess, + launcher.invalidateLauncherCache +); + authenticated.post( "/org/:orgId/launcher/views", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index b9edafc22..1c3fed44c 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -10,3 +10,4 @@ export { updateLauncherView } from "./updateLauncherView"; export { deleteLauncherView } from "./deleteLauncherView"; export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView"; export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView"; +export { invalidateLauncherCache } from "./invalidateLauncherCache"; diff --git a/server/routers/launcher/invalidateLauncherCache.ts b/server/routers/launcher/invalidateLauncherCache.ts new file mode 100644 index 000000000..5e4ff1618 --- /dev/null +++ b/server/routers/launcher/invalidateLauncherCache.ts @@ -0,0 +1,65 @@ +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; + +async function invalidateLauncherCacheForUser( + orgId: string, + userId: string +): Promise { + const prefixes = [ + `launcherAccessibleIds:${orgId}:${userId}:`, + `launcher:groups:${orgId}:${userId}:`, + `launcher:results:${orgId}:${userId}:`, + `launcher:scale:counts:${orgId}:${userId}:` + ]; + + const keys = ( + await Promise.all( + prefixes.map((prefix) => cache.keysWithPrefix(prefix)) + ) + ).flat(); + + if (keys.length > 0) { + await cache.del(keys); + } +} + +export async function invalidateLauncherCache( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + await invalidateLauncherCacheForUser(orgId, userId); + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher cache invalidated successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error invalidating launcher cache:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 9f9d44d11..3f1249efc 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -44,7 +44,7 @@ import { type LauncherViewConfig, type LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; @@ -105,6 +105,7 @@ export default function ResourceLauncher({ const { toast } = useToast(); const { env } = useEnvContext(); const api = createApiClient({ env }); + const queryClient = useQueryClient(); const router = useRouter(); const { navigate, isNavigating, searchParams } = useNavigationContext(); const [isRefreshing, startRefreshTransition] = useTransition(); @@ -489,6 +490,10 @@ export default function ResourceLauncher({ const refreshData = () => { startRefreshTransition(async () => { try { + await api.post(`/org/${orgId}/launcher/invalidate-cache`); + await queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "LAUNCHER"] + }); router.refresh(); } catch { toast({ From 4087d7fb6b67fdb69f3b38d48ba7397683082c36 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 3 Jul 2026 11:18:06 -0400 Subject: [PATCH 8/9] remove text from refresh button --- src/components/resource-launcher/LauncherRefreshButton.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx index 854128736..1d0a3d2f3 100644 --- a/src/components/resource-launcher/LauncherRefreshButton.tsx +++ b/src/components/resource-launcher/LauncherRefreshButton.tsx @@ -23,9 +23,8 @@ export function LauncherRefreshButton({ className="shrink-0" > - {t("refresh")} ); } From a82bae8f9387ce89ace980531e304216063499a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 6 Jul 2026 12:45:07 -0400 Subject: [PATCH 9/9] add resource info to side panel --- messages/en-US.json | 11 +- server/routers/external.ts | 8 + src/components/CopyToClipboard.tsx | 15 +- src/components/InfoSection.tsx | 14 +- .../LauncherResourcePanel.tsx | 507 +++++++++++++++++- .../resource-launcher/ResourceLauncher.tsx | 27 + src/lib/launcherResourceDetails.ts | 93 ++++ src/lib/queries.ts | 47 ++ 8 files changed, 710 insertions(+), 12 deletions(-) create mode 100644 src/lib/launcherResourceDetails.ts diff --git a/messages/en-US.json b/messages/en-US.json index 90be25679..c7164aca3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3607,7 +3607,16 @@ "resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?", "resourceLauncherDeleteViewConfirm": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", - "resourceLauncherResourceDetailsDescription": "View details for this resource.", + "resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.", + "resourceLauncherResourceDetails": "Resource Details", + "resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.", + "resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.", + "resourceLauncherPrivateClientRequiredTitle": "Client Connection Required", + "resourceLauncherDownloadClient": "Download client", + "resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.", + "resourceLauncherNoPortRestrictions": "No port restrictions", + "resourceLauncherTcp": "TCP", + "resourceLauncherUdp": "UDP", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", diff --git a/server/routers/external.ts b/server/routers/external.ts index 33fcb3ab3..8a22393ca 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -327,6 +327,14 @@ authenticated.get( siteResource.listAllSiteResourcesByOrg ); +authenticated.get( + "/org/:orgId/site-resource/:siteResourceId", + verifyOrgAccess, + verifySiteResourceAccess, + verifyUserHasAction(ActionsEnum.getSiteResource), + siteResource.getSiteResource +); + authenticated.get( "/site-resource/:siteResourceId", verifySiteResourceAccess, diff --git a/src/components/CopyToClipboard.tsx b/src/components/CopyToClipboard.tsx index 7187694f7..147972e78 100644 --- a/src/components/CopyToClipboard.tsx +++ b/src/components/CopyToClipboard.tsx @@ -1,3 +1,4 @@ +import { cn } from "@app/lib/cn"; import { Check, Copy } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; @@ -7,12 +8,14 @@ type CopyToClipboardProps = { text: string; displayText?: string; isLink?: boolean; + className?: string; }; const CopyToClipboard = ({ text, displayText, - isLink + isLink, + className }: CopyToClipboardProps) => { const [copied, setCopied] = useState(false); @@ -48,14 +51,20 @@ const CopyToClipboard = ({ href={text} target="_blank" rel="noopener noreferrer" - className="truncate hover:underline text-sm min-w-0 max-w-full" + className={cn( + "truncate hover:underline text-sm min-w-0 max-w-full", + className + )} title={text} // Shows full text on hover > {displayValue} ) : ( -; + } + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + + if (canLink && href) { + return ( + + ); + } + + return ( + + ); +} + +function HealthStatusDisplay({ + health +}: { + health: string | null | undefined; +}) { + const t = useTranslations(); + const status = health ?? "unknown"; + + if (status === "healthy") { + return ( +
+ + {t("resourcesTableHealthy")} +
+ ); + } + + if (status === "degraded") { + return ( +
+ + {t("resourcesTableDegraded")} +
+ ); + } + + if (status === "unhealthy") { + return ( +
+ + {t("resourcesTableUnhealthy")} +
+ ); + } + + return ( +
+ + {t("resourcesTableUnknown")} +
+ ); +} + +const PUBLIC_AUTH_BROWSER_MODES = ["http", "ssh", "rdp", "vnc"]; + +function AuthMethodStatusDisplay({ enabled }: { enabled: boolean }) { + const t = useTranslations(); + + return ( +
+ {enabled ? ( + + ) : ( + + )} + {enabled ? t("enabled") : t("disabled")} +
+ ); +} + +function PublicResourceAuthMethods({ + authInfo +}: { + authInfo: GetResourceAuthInfoResponse; +}) { + const t = useTranslations(); + + const authMethods = [ + { + key: "sso", + title: t("policyAuthSsoTitle"), + enabled: authInfo.sso + }, + { + key: "password", + title: t("policyAuthPasscodeTitle"), + enabled: authInfo.password + }, + { + key: "pincode", + title: t("policyAuthPincodeTitle"), + enabled: authInfo.pincode + }, + { + key: "whitelist", + title: t("policyAuthEmailTitle"), + enabled: authInfo.whitelist + }, + { + key: "headerAuth", + title: t("policyAuthHeaderAuthTitle"), + enabled: authInfo.headerAuth + } + ]; + + return ( + + + + {t("authentication")} + + + {t("resourceLauncherAuthMethodsDescription")} + + + + + {authMethods.map((method) => ( + + {method.title} + + + + + ))} + + + + ); +} + +function PublicResourceDetails({ + launcherResource, + resource, + authInfo +}: { + launcherResource: LauncherResource; + resource: GetResourceResponse; + authInfo: GetResourceAuthInfoResponse; +}) { + const t = useTranslations(); + const supportsAuth = PUBLIC_AUTH_BROWSER_MODES.includes( + resource.mode || "" + ); + const authState = derivePublicAuthState(resource.mode, authInfo); + const infoSectionCount = supportsAuth ? 4 : 3; + + return ( +
+ + + + {t("resourceLauncherResourceDetails")} + + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {t("type")} + + {formatPublicResourceType(resource)} + + + + {t("access")} + + + + + {supportsAuth ? ( + + + {t("authentication")} + + + {authState === "protected" ? ( +
+ + {t("protected")} +
+ ) : ( +
+ + {t("notProtected")} +
+ )} +
+
+ ) : null} + + {t("health")} + + + + +
+
+
+ {supportsAuth ? ( + + ) : null} +
+ ); +} + +function PrivateResourceDetails({ + launcherResource, + resource +}: { + launcherResource: LauncherResource; + resource: GetSiteResourceResponse; +}) { + const t = useTranslations(); + const modeLabel: Record = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + http: t("editInternalResourceDialogModeHttp"), + ssh: t("editInternalResourceDialogModeSsh") + }; + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode, + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + const portRestrictions = formatPortRestrictionDisplay(resource); + const showAlias = resource.mode !== "cidr" && resource.mode !== "http"; + const showDestination = !( + resource.mode === "ssh" && resource.authDaemonMode === "native" + ); + const infoSectionCount = + 2 + (showDestination ? 1 : 0) + (showAlias ? 1 : 0) + 1; + + return ( +
+ + + + {t("resourceLauncherPrivateClientRequiredTitle")} + + + + {t("resourceLauncherPrivateClientRequired")}{" "} + + {t("resourceLauncherDownloadClient")} + + + + + + + + + + {t("resourceLauncherResourceDetails")} + + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {t("type")} + + {modeLabel[resource.mode]} + + + + {t("access")} + + + + + {showDestination ? ( + + + {t("editInternalResourceDialogDestination")} + + + {destination || "-"} + + + ) : null} + {showAlias ? ( + + + {t("editInternalResourceDialogAlias")} + + + {resource.alias?.trim() + ? resource.alias + : "-"} + + + ) : null} + + + {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} +
+ )} +
+
+
+
+
+
+ ); +} + +function LauncherResourcePanelBody({ + orgId, + resource, + open +}: { + orgId: string; + resource: LauncherResource; + open: boolean; +}) { + const t = useTranslations(); + const { data, isPending, isError } = useQuery({ + ...launcherQueries.resourceDetail(orgId, resource), + enabled: open + }); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (isError || !data) { + return ( +

+ {t("resourceLauncherFailedToLoadDetails")} +

+ ); + } + + const detail = data as LauncherResourceDetailResult; + + if (detail.resourceType === "public") { + return ( + + ); + } + + return ( + + ); +} + export function LauncherResourcePanel({ open, onOpenChange, @@ -37,11 +529,16 @@ export function LauncherResourcePanel({ {resource?.name ?? ""} - - {t("resourceLauncherResourceDetailsDescription")} - - + + {resource ? ( + + ) : null} +