From f0efa4203b2f8a520f0f3f004d8df459accc9121 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:03:19 -0400 Subject: [PATCH 01/21] basic functionality --- .cursor/rules/Migrations.mdc | 5 + messages/en-US.json | 43 + server/db/pg/schema/schema.ts | 15 + server/db/sqlite/schema/schema.ts | 15 + server/routers/external.ts | 37 + server/routers/integration.ts | 37 + server/routers/launcher/createLauncherView.ts | 118 ++ server/routers/launcher/deleteLauncherView.ts | 97 ++ .../routers/launcher/formatLauncherAccess.ts | 139 +++ server/routers/launcher/index.ts | 7 + .../launcher/launcherResourceAccess.ts | 1090 ++++++++++++++++ server/routers/launcher/listLauncherGroups.ts | 73 ++ .../routers/launcher/listLauncherResources.ts | 78 ++ server/routers/launcher/listLauncherViews.ts | 83 ++ server/routers/launcher/types.ts | 129 ++ server/routers/launcher/updateLauncherView.ts | 164 +++ src/app/[orgId]/page.tsx | 24 +- src/components/Layout.tsx | 16 +- src/components/LayoutHeader.tsx | 53 +- src/components/LayoutSidebar.tsx | 55 +- src/components/MemberResourcesPortal.tsx | 1101 ----------------- src/components/labels-selector.tsx | 15 + .../resource-launcher/LauncherCopyIcon.tsx | 43 + .../LauncherFilterPopover.tsx | 142 +++ .../resource-launcher/LauncherGroupList.tsx | 201 +++ .../LauncherGroupSection.tsx | 164 +++ .../LauncherGroupTrigger.tsx | 67 + .../resource-launcher/LauncherLabelsRow.tsx | 175 +++ .../resource-launcher/LauncherOrgSelector.tsx | 114 ++ .../LauncherResourceAccess.tsx | 69 ++ .../LauncherResourceCard.tsx | 73 ++ .../LauncherResourceGrid.tsx | 26 + .../LauncherResourceIcon.tsx | 45 + .../LauncherResourceList.tsx | 30 + .../resource-launcher/LauncherResourceRow.tsx | 87 ++ .../LauncherSettingsMenu.tsx | 148 +++ .../resource-launcher/LauncherSortButton.tsx | 38 + .../resource-launcher/LauncherViewTabs.tsx | 121 ++ .../resource-launcher/ResourceLauncher.tsx | 532 ++++++++ .../useLauncherResourceAction.ts | 88 ++ src/lib/launcherLocalStorage.ts | 98 ++ src/lib/launcherResourceAccess.ts | 123 ++ src/lib/launcherUrlState.ts | 292 +++++ src/lib/queries.ts | 100 ++ 44 files changed, 5048 insertions(+), 1122 deletions(-) create mode 100644 .cursor/rules/Migrations.mdc create mode 100644 server/routers/launcher/createLauncherView.ts create mode 100644 server/routers/launcher/deleteLauncherView.ts create mode 100644 server/routers/launcher/formatLauncherAccess.ts create mode 100644 server/routers/launcher/index.ts create mode 100644 server/routers/launcher/launcherResourceAccess.ts create mode 100644 server/routers/launcher/listLauncherGroups.ts create mode 100644 server/routers/launcher/listLauncherResources.ts create mode 100644 server/routers/launcher/listLauncherViews.ts create mode 100644 server/routers/launcher/types.ts create mode 100644 server/routers/launcher/updateLauncherView.ts delete mode 100644 src/components/MemberResourcesPortal.tsx create mode 100644 src/components/resource-launcher/LauncherCopyIcon.tsx create mode 100644 src/components/resource-launcher/LauncherFilterPopover.tsx create mode 100644 src/components/resource-launcher/LauncherGroupList.tsx create mode 100644 src/components/resource-launcher/LauncherGroupSection.tsx create mode 100644 src/components/resource-launcher/LauncherGroupTrigger.tsx create mode 100644 src/components/resource-launcher/LauncherLabelsRow.tsx create mode 100644 src/components/resource-launcher/LauncherOrgSelector.tsx create mode 100644 src/components/resource-launcher/LauncherResourceAccess.tsx create mode 100644 src/components/resource-launcher/LauncherResourceCard.tsx create mode 100644 src/components/resource-launcher/LauncherResourceGrid.tsx create mode 100644 src/components/resource-launcher/LauncherResourceIcon.tsx create mode 100644 src/components/resource-launcher/LauncherResourceList.tsx create mode 100644 src/components/resource-launcher/LauncherResourceRow.tsx create mode 100644 src/components/resource-launcher/LauncherSettingsMenu.tsx create mode 100644 src/components/resource-launcher/LauncherSortButton.tsx create mode 100644 src/components/resource-launcher/LauncherViewTabs.tsx create mode 100644 src/components/resource-launcher/ResourceLauncher.tsx create mode 100644 src/components/resource-launcher/useLauncherResourceAction.ts create mode 100644 src/lib/launcherLocalStorage.ts create mode 100644 src/lib/launcherResourceAccess.ts create mode 100644 src/lib/launcherUrlState.ts diff --git a/.cursor/rules/Migrations.mdc b/.cursor/rules/Migrations.mdc new file mode 100644 index 000000000..d9562b4e1 --- /dev/null +++ b/.cursor/rules/Migrations.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +Don't write or edit migrations in `server/setup` unless specificall instructed to do so. diff --git a/messages/en-US.json b/messages/en-US.json index c7964f8be..d27196be2 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2077,6 +2077,7 @@ "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", + "selectLabels": "Select labels", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", "clientInstallOlmDescription": "Install the machine client for your system", @@ -2304,6 +2305,7 @@ "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", "multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}", + "labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", @@ -3542,6 +3544,47 @@ "memberPortalEmailWhitelist": "Email Whitelist", "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...", + "resourceLauncherDefaultView": "Default", + "resourceLauncherSaveView": "Save View", + "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherSaveAsNewView": "Save as New View", + "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", + "resourceLauncherSaveForEveryone": "Save for Everyone", + "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", + "resourceLauncherMakePersonal": "Make Personal", + "resourceLauncherFilter": "Filter", + "resourceLauncherSort": "Sort", + "resourceLauncherSortAscending": "Sort ascending", + "resourceLauncherSortDescending": "Sort descending", + "resourceLauncherSettings": "Settings", + "resourceLauncherGroupBy": "Group By", + "resourceLauncherGroupBySite": "Site", + "resourceLauncherGroupByLabel": "Label", + "resourceLauncherLayout": "Layout", + "resourceLauncherLayoutGrid": "Grid", + "resourceLauncherLayoutList": "List", + "resourceLauncherShowLabels": "Show Label Tags", + "resourceLauncherShowSiteTags": "Show Site Tags", + "resourceLauncherShowRecents": "Show Recents", + "resourceLauncherDeleteView": "Delete View", + "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherNoResourcesInGroup": "No resources in this group", + "resourceLauncherCopiedToClipboard": "Copied to clipboard", + "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", + "resourceLauncherViewNamePlaceholder": "View name", + "resourceLauncherViewNameLabel": "View Name", + "resourceLauncherViewSaved": "View saved", + "resourceLauncherViewSavedDescription": "Your launcher view has been saved.", + "resourceLauncherViewSaveFailed": "Failed to save view", + "resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.", + "resourceLauncherViewDeleted": "View deleted", + "resourceLauncherViewDeletedDescription": "The launcher view has been deleted.", + "resourceLauncherViewDeleteFailed": "Failed to delete view", + "resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.", "memberPortalPrevious": "Previous", "memberPortalNext": "Next", "httpSettings": "HTTP Settings", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 1b48aa520..aa8c4b58f 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -218,6 +218,20 @@ export const labels = pgTable("labels", { .notNull() }); +export const launcherViews = pgTable("launcherViews", { + viewId: serial("viewId").primaryKey(), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: varchar("name").notNull(), + config: text("config").notNull(), + createdAt: varchar("createdAt").notNull(), + updatedAt: varchar("updatedAt").notNull() +}); + export const siteLabels = pgTable( "siteLabels", { @@ -1550,6 +1564,7 @@ export type RoundTripMessageTracker = InferSelectModel< export type Network = InferSelectModel; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type RolePolicy = InferSelectModel; export type UserPolicy = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0c4a143f5..ac2ee0890 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -221,6 +221,20 @@ export const labels = sqliteTable("labels", { .notNull() }); +export const launcherViews = sqliteTable("launcherViews", { + viewId: integer("viewId").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + name: text("name").notNull(), + config: text("config").notNull(), + createdAt: text("createdAt").notNull(), + updatedAt: text("updatedAt").notNull() +}); + export const siteLabels = sqliteTable( "siteLabels", { @@ -1549,6 +1563,7 @@ export type RoundTripMessageTracker = InferSelectModel< >; export type StatusHistory = InferSelectModel; export type Label = InferSelectModel; +export type LauncherView = InferSelectModel; export type ResourcePolicy = InferSelectModel; export type ResourcePolicyPincode = InferSelectModel< typeof resourcePolicyPincode diff --git a/server/routers/external.ts b/server/routers/external.ts index 960c00249..b4ca30cff 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -17,6 +17,7 @@ import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; +import * as launcher from "./launcher"; import * as newt from "./newt"; import * as olm from "./olm"; import * as serverInfo from "./serverInfo"; @@ -455,6 +456,42 @@ authenticated.get( resource.getUserResources ); +authenticated.get( + "/org/:orgId/launcher/groups", + verifyOrgAccess, + launcher.listLauncherGroups +); + +authenticated.get( + "/org/:orgId/launcher/resources", + verifyOrgAccess, + launcher.listLauncherResources +); + +authenticated.get( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.listLauncherViews +); + +authenticated.post( + "/org/:orgId/launcher/views", + verifyOrgAccess, + launcher.createLauncherView +); + +authenticated.put( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.updateLauncherView +); + +authenticated.delete( + "/org/:orgId/launcher/views/:viewId", + verifyOrgAccess, + launcher.deleteLauncherView +); + authenticated.get( "/org/:orgId/user-resource-aliases", verifyOrgAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 93857e1db..0bbe12163 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,6 +13,7 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; +import * as launcher from "./launcher"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -159,6 +160,42 @@ authenticated.get( verifyApiKeyOrgAccess, resource.getUserResources ); + +authenticated.get( + "/org/:orgId/launcher/groups", + verifyApiKeyOrgAccess, + launcher.listLauncherGroups +); + +authenticated.get( + "/org/:orgId/launcher/resources", + verifyApiKeyOrgAccess, + launcher.listLauncherResources +); + +authenticated.get( + "/org/:orgId/launcher/views", + verifyApiKeyOrgAccess, + launcher.listLauncherViews +); + +authenticated.post( + "/org/:orgId/launcher/views", + verifyApiKeyOrgAccess, + launcher.createLauncherView +); + +authenticated.put( + "/org/:orgId/launcher/views/:viewId", + verifyApiKeyOrgAccess, + launcher.updateLauncherView +); + +authenticated.delete( + "/org/:orgId/launcher/views/:viewId", + verifyApiKeyOrgAccess, + launcher.deleteLauncherView +); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts new file mode 100644 index 000000000..fb7ff2630 --- /dev/null +++ b/server/routers/launcher/createLauncherView.ts @@ -0,0 +1,118 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; +import { launcherViewConfigSchema } from "./types"; + +const createLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128), + config: launcherViewConfigSchema, + orgWide: z.boolean().optional().default(false) +}); + +export async function createLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = createLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + if (parsed.data.orgWide) { + const canManageOrgWide = await isOrgAdminOrOwner( + orgId, + userId, + userRoleIds + ); + if (!canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can create org-wide views" + ) + ); + } + } + + const now = moment().toISOString(); + const [created] = await db + .insert(launcherViews) + .values({ + orgId, + userId: parsed.data.orgWide ? null : userId, + name: parsed.data.name, + config: JSON.stringify(parsed.data.config), + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: { + viewId: created.viewId, + orgId: created.orgId, + userId: created.userId, + name: created.name, + config: launcherViewConfigSchema.parse( + JSON.parse(created.config) + ), + createdAt: created.createdAt, + updatedAt: created.updatedAt, + isOrgWide: created.userId == null + }, + success: true, + error: false, + message: "Launcher view created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error creating launcher 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 new file mode 100644 index 000000000..c68c6530c --- /dev/null +++ b/server/routers/launcher/deleteLauncherView.ts @@ -0,0 +1,97 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; + +export async function deleteLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + + if (!isPersonalView && !(isOrgWideView && isAdmin)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to delete this view" + ) + ); + } + + await db.delete(launcherViews).where(eq(launcherViews.viewId, viewId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Launcher view deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error deleting launcher view:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts new file mode 100644 index 000000000..31f625bb7 --- /dev/null +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -0,0 +1,139 @@ +export type SiteResourceDestinationInput = { + mode: "host" | "cidr" | "http" | "ssh"; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; +}; + +export function resolveHttpHttpsDisplayPort( + mode: "http", + destinationPort: number | null +): number { + if (destinationPort != null) { + return destinationPort; + } + return 80; +} + +export function formatSiteResourceDestinationDisplay( + row: SiteResourceDestinationInput +): string { + if (!row.destination) { + return ""; + } + const { mode, destination, destinationPort, scheme } = row; + if (mode !== "http") { + return destination; + } + const port = resolveHttpHttpsDisplayPort(mode, destinationPort); + const downstreamScheme = scheme ?? "http"; + const hostPart = + destination.includes(":") && !destination.startsWith("[") + ? `[${destination}]` + : destination; + return `${downstreamScheme}://${hostPart}:${port}`; +} + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + const port = resource.proxyPort?.toString() ?? ""; + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts new file mode 100644 index 000000000..f23b266ea --- /dev/null +++ b/server/routers/launcher/index.ts @@ -0,0 +1,7 @@ +export * from "./types"; +export { listLauncherGroups } from "./listLauncherGroups"; +export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherViews } from "./listLauncherViews"; +export { createLauncherView } from "./createLauncherView"; +export { updateLauncherView } from "./updateLauncherView"; +export { deleteLauncherView } from "./deleteLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts new file mode 100644 index 000000000..95b2b16e1 --- /dev/null +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -0,0 +1,1090 @@ +import { db } from "@server/db"; +import { + labels, + launcherViews, + resourceLabels, + resources, + rolePolicies, + roleResources, + roles, + roleSiteResources, + siteNetworks, + siteResourceLabels, + siteResources, + sites, + targets, + userOrgRoles, + userOrgs, + userPolicies, + userResources, + userSiteResources +} from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + and, + asc, + countDistinct, + eq, + inArray, + like, + or, + sql +} from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { + formatPublicResourceAccess, + formatSiteResourceAccess +} from "./formatLauncherAccess"; +import { + LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherGroup, + type LauncherLabel, + type LauncherListQuery, + type LauncherResource, + parseIdListParam +} from "./types"; + +const effectiveResourcePolicyId = sql< + number | null +>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`; + +export type AccessibleIds = { + resourceIds: number[]; + siteResourceIds: number[]; +}; + +export async function verifyLauncherOrgMembership( + orgId: string, + userId: string +): Promise<{ userRoleIds: number[] }> { + const [userOrg] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!userOrg) { + throw createHttpError(HttpCode.FORBIDDEN, "User not in organization"); + } + + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ) + .then((rows) => rows.map((r) => r.roleId)); + + return { userRoleIds }; +} + +export async function isOrgAdminOrOwner( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const [membership] = await db + .select({ isOwner: userOrgs.isOwner }) + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (membership?.isOwner) { + return true; + } + + if (userRoleIds.length === 0) { + return false; + } + + const adminRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + eq(roles.isAdmin, true), + inArray(roles.roleId, userRoleIds) + ) + ) + .limit(1); + + return adminRoles.length > 0; +} + +export async function resolveAccessibleIds( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const [ + directResources, + roleResourceResults, + directPolicyResourceResults, + rolePolicyResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ + db + .select({ resourceId: userResources.resourceId }) + .from(userResources) + .innerJoin( + resources, + eq(userResources.resourceId, resources.resourceId) + ) + .where( + and( + eq(userResources.userId, userId), + eq(resources.orgId, orgId) + ) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .innerJoin( + resources, + eq(roleResources.resourceId, resources.resourceId) + ) + .where( + and( + inArray(roleResources.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + userPolicies, + eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId) + ) + .where( + and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId)) + ), + userRoleIds.length > 0 + ? db + .select({ resourceId: resources.resourceId }) + .from(resources) + .innerJoin( + rolePolicies, + eq( + effectiveResourcePolicyId, + rolePolicies.resourcePolicyId + ) + ) + .where( + and( + inArray(rolePolicies.roleId, userRoleIds), + eq(resources.orgId, orgId) + ) + ) + : Promise.resolve([]), + db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)), + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]) + ]); + + return { + resourceIds: Array.from( + new Set([ + ...directResources.map((r) => r.resourceId), + ...roleResourceResults.map((r) => r.resourceId), + ...directPolicyResourceResults.map((r) => r.resourceId), + ...rolePolicyResourceResults.map((r) => r.resourceId) + ]) + ), + siteResourceIds: Array.from( + new Set([ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]) + ) + }; +} + +function searchPattern(query: string) { + return `%${query.trim()}%`; +} + +function buildSearchConditionForPublic( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${resources.name})`, pattern), + like(sql`LOWER(${resources.fullDomain})`, pattern), + like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + resources.resourceId, + db + .select({ id: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + labels, + eq(labels.labelId, resourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +function buildSearchConditionForSiteResource( + query: string, + labelsFeatureEnabled: boolean +) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + const queryList = [ + like(sql`LOWER(${siteResources.name})`, pattern), + like(sql`LOWER(${siteResources.destination})`, pattern), + like(sql`LOWER(${siteResources.alias})`, pattern), + like(sql`LOWER(${siteResources.fullDomain})`, pattern), + like(sql`LOWER(${siteResources.aliasAddress})`, pattern) + ]; + + if (labelsFeatureEnabled) { + queryList.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(like(sql`LOWER(${labels.name})`, pattern)) + ) + ); + } + + return or(...queryList); +} + +async function labelsEnabled(orgId: string): Promise { + return isLicensedOrSubscribed(orgId, tierMatrix.labels); +} + +async function fetchLabelsForResources( + orgId: string, + resourceIds: number[], + siteResourceIds: number[] +): Promise<{ + byResourceId: Map; + bySiteResourceId: Map; +}> { + const byResourceId = new Map(); + const bySiteResourceId = new Map(); + + if (!(await labelsEnabled(orgId))) { + return { byResourceId, bySiteResourceId }; + } + + const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ + resourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + resourceId: resourceLabels.resourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .where(inArray(resourceLabels.resourceId, resourceIds)) + .orderBy(asc(resourceLabels.resourceLabelId)), + siteResourceIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + siteResourceId: siteResourceLabels.siteResourceId, + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIds + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)) + ]); + + for (const row of resourceLabelRows) { + const list = byResourceId.get(row.resourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + byResourceId.set(row.resourceId, list); + } + + for (const row of siteResourceLabelRows) { + const list = bySiteResourceId.get(row.siteResourceId) ?? []; + list.push({ + labelId: row.labelId, + name: row.name, + color: row.color + }); + bySiteResourceId.set(row.siteResourceId, list); + } + + return { byResourceId, bySiteResourceId }; +} + +type SiteGroupRow = { + siteId: number; + name: string; + type: string; + online: boolean; + itemCount: number; +}; + +async function listSiteGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelsFeatureEnabled = await labelsEnabled(orgId); + const searchPublic = buildSearchConditionForPublic( + query.query, + labelsFeatureEnabled + ); + const searchSite = buildSearchConditionForSiteResource( + query.query, + labelsFeatureEnabled + ); + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + let publicQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + publicQuery = publicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + publicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const publicRows = await publicQuery + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + let siteResourceQuery = db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + siteResourceQuery = siteResourceQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + siteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const siteRows = await siteResourceQuery + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( + (row) => ({ + groupKey: String(row.siteId), + name: row.name, + groupType: "site" as const, + itemCount: row.itemCount, + siteType: row.type, + siteOnline: row.online + }) + ); + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +async function listLabelGroups( + orgId: string, + accessible: AccessibleIds, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + const labelCountMap = new Map< + number, + { labelId: number; name: string; color: string; itemCount: number } + >(); + let unlabeledCount = 0; + + if (!(await labelsEnabled(orgId))) { + return { groups: [], total: 0 }; + } + + const matchesLabelFilters = (labelId: number) => + labelFilterIds.length === 0 || labelFilterIds.includes(labelId); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + const searchPublic = buildSearchConditionForPublic(query.query, true); + if (searchPublic) { + publicConditions.push(searchPublic); + } + if (siteFilterIds.length > 0) { + publicConditions.push(inArray(targets.siteId, siteFilterIds)); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(resources.resourceId) + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledPublicIds = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .where(and(...publicConditions)); + + const labeledSet = new Set(labeledPublicIds.map((r) => r.resourceId)); + unlabeledCount += accessible.resourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + const searchSite = buildSearchConditionForSiteResource( + query.query, + true + ); + if (searchSite) { + siteConditions.push(searchSite); + } + if (siteFilterIds.length > 0) { + siteConditions.push(inArray(sites.siteId, siteFilterIds)); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions, eq(labels.orgId, orgId))) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + if (!matchesLabelFilters(row.labelId)) { + continue; + } + const existing = labelCountMap.get(row.labelId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + labelCountMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color, + itemCount: Number(row.itemCount) + }); + } + } + + const labeledSiteIds = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)); + + const labeledSet = new Set(labeledSiteIds.map((r) => r.siteResourceId)); + unlabeledCount += accessible.siteResourceIds.filter( + (id) => !labeledSet.has(id) + ).length; + } + + let groups: LauncherGroup[] = Array.from(labelCountMap.values()).map( + (row) => ({ + groupKey: String(row.labelId), + name: row.name, + groupType: "label" as const, + itemCount: row.itemCount, + labelColor: row.color + }) + ); + + if (unlabeledCount > 0 && labelFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_UNLABELED_GROUP_KEY, + name: "Unlabeled", + groupType: "label", + itemCount: unlabeledCount, + labelColor: "#a1a1aa" + }); + } + + groups.sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return query.order === "desc" ? -cmp : cmp; + }); + + const total = groups.length; + const offset = (query.page - 1) * query.pageSize; + return { + groups: groups.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listLauncherGroupsForUser( + orgId: string, + userId: string, + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + if (query.groupBy === "label") { + return listLabelGroups(orgId, accessible, query); + } + + return listSiteGroups(orgId, accessible, query); +} + +async function mapPublicResources( + orgId: string, + resourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + mode: resources.mode, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + proxyPort: resources.proxyPort, + wildcard: resources.wildcard, + enabled: resources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online + }) + .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), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `public:${row.resourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatPublicResourceAccess({ + mode: row.mode, + fullDomain: row.fullDomain, + ssl: row.ssl, + proxyPort: row.proxyPort, + wildcard: row.wildcard + }); + + result.push({ + launcherResourceKey: key, + resourceType: "public", + resourceId: row.resourceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.byResourceId.get(row.resourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +async function mapSiteResources( + orgId: string, + siteResourceIds: number[], + labelMaps: Awaited>, + siteIdFilter?: number +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const rows = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + mode: siteResources.mode, + destination: siteResources.destination, + destinationPort: siteResources.destinationPort, + scheme: siteResources.scheme, + ssl: siteResources.ssl, + fullDomain: siteResources.fullDomain, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + enabled: siteResources.enabled, + siteId: sites.siteId, + siteName: sites.name, + siteType: sites.type, + siteOnline: sites.online + }) + .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), + siteIdFilter != null + ? eq(sites.siteId, siteIdFilter) + : undefined + ) + ); + + const seen = new Set(); + const result: LauncherResource[] = []; + + for (const row of rows) { + const key = `site:${row.siteResourceId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + + const access = formatSiteResourceAccess({ + mode: row.mode, + destination: row.destination, + destinationPort: row.destinationPort, + scheme: row.scheme, + ssl: row.ssl, + fullDomain: row.fullDomain, + alias: row.alias, + aliasAddress: row.aliasAddress + }); + + result.push({ + launcherResourceKey: key, + resourceType: "site", + resourceId: row.siteResourceId, + siteResourceId: row.siteResourceId, + name: row.name, + ...access, + iconUrl: null, + enabled: row.enabled, + mode: row.mode, + labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [], + site: + row.siteId != null + ? { + siteId: row.siteId, + name: row.siteName!, + type: row.siteType!, + online: row.siteOnline ?? undefined + } + : undefined + }); + } + + return result; +} + +function filterResourcesByLabel( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_UNLABELED_GROUP_KEY) { + return items.filter((item) => item.labels.length === 0); + } + const labelId = Number.parseInt(groupKey, 10); + return items.filter((item) => + item.labels.some((label) => label.labelId === labelId) + ); +} + +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" +): LauncherResource[] { + return [...items].sort((a, b) => { + const cmp = a.name.localeCompare(b.name, undefined, { + sensitivity: "base" + }); + return order === "desc" ? -cmp : cmp; + }); +} + +export async function listLauncherResourcesForUser( + orgId: string, + userId: string, + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + + const siteFilterIds = parseIdListParam(query.siteIds); + const labelFilterIds = parseIdListParam(query.labelIds); + + let filteredResourceIds = accessible.resourceIds; + let filteredSiteResourceIds = accessible.siteResourceIds; + + if (siteFilterIds.length > 0 && accessible.resourceIds.length > 0) { + const publicOnSites = await db + .select({ resourceId: resources.resourceId }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + inArray(resources.resourceId, accessible.resourceIds), + inArray(targets.siteId, siteFilterIds) + ) + ); + filteredResourceIds = publicOnSites.map((r) => r.resourceId); + } + + if (siteFilterIds.length > 0 && accessible.siteResourceIds.length > 0) { + const privateOnSites = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + inArray( + siteResources.siteResourceId, + accessible.siteResourceIds + ), + inArray(siteNetworks.siteId, siteFilterIds) + ) + ); + filteredSiteResourceIds = privateOnSites.map((r) => r.siteResourceId); + } + + if (labelFilterIds.length > 0) { + if (filteredResourceIds.length > 0) { + const withLabels = await db + .select({ resourceId: resourceLabels.resourceId }) + .from(resourceLabels) + .where( + and( + inArray(resourceLabels.resourceId, filteredResourceIds), + inArray(resourceLabels.labelId, labelFilterIds) + ) + ); + filteredResourceIds = withLabels.map((r) => r.resourceId); + } + if (filteredSiteResourceIds.length > 0) { + const withLabels = await db + .select({ siteResourceId: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .where( + and( + inArray( + siteResourceLabels.siteResourceId, + filteredSiteResourceIds + ), + inArray(siteResourceLabels.labelId, labelFilterIds) + ) + ); + filteredSiteResourceIds = withLabels.map((r) => r.siteResourceId); + } + } + + const labelMaps = await fetchLabelsForResources( + orgId, + filteredResourceIds, + filteredSiteResourceIds + ); + + const siteIdFilter = + query.groupBy === "site" + ? Number.parseInt(query.groupKey, 10) + : undefined; + + const [publicItems, siteItems] = await Promise.all([ + mapPublicResources( + orgId, + filteredResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ), + mapSiteResources( + orgId, + filteredSiteResourceIds, + labelMaps, + Number.isFinite(siteIdFilter) ? siteIdFilter : undefined + ) + ]); + + let items = [...publicItems, ...siteItems]; + items = filterResourcesBySearch(items, query.query); + + if (query.groupBy === "label") { + items = filterResourcesByLabel(items, query.groupKey); + } + + items = sortLauncherResources(items, query.order); + + const total = items.length; + const offset = (query.page - 1) * query.pageSize; + return { + resources: items.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts new file mode 100644 index 000000000..69ad1a10d --- /dev/null +++ b/server/routers/launcher/listLauncherGroups.ts @@ -0,0 +1,73 @@ +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { listLauncherGroupsForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +export async function listLauncherGroups( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = launcherListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { groups, total } = await listLauncherGroupsForUser( + orgId, + userId, + parsed.data + ); + + return response(res, { + data: { + groups, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher groups retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher groups:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts new file mode 100644 index 000000000..a4b2be993 --- /dev/null +++ b/server/routers/launcher/listLauncherResources.ts @@ -0,0 +1,78 @@ +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +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 { listLauncherResourcesForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +const listLauncherResourcesQuerySchema = launcherListQuerySchema.extend({ + groupKey: z.string().min(1) +}); + +export async function listLauncherResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + const parsed = listLauncherResourcesQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { resources, total } = await listLauncherResourcesForUser( + orgId, + userId, + parsed.data + ); + + return response(res, { + data: { + resources, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher resources retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher resources:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts new file mode 100644 index 000000000..019dbd2da --- /dev/null +++ b/server/routers/launcher/listLauncherViews.ts @@ -0,0 +1,83 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +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"; +import { verifyLauncherOrgMembership } from "./launcherResourceAccess"; + +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 async function listLauncherViews( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + await verifyLauncherOrgMembership(orgId, userId); + + const rows = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.orgId, orgId), + or( + eq(launcherViews.userId, userId), + isNull(launcherViews.userId) + ) + ) + ); + + return response(res, { + data: { + views: rows.map(mapViewRow) + }, + success: true, + error: false, + message: "Launcher views retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher views:", 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 new file mode 100644 index 000000000..6774a1549 --- /dev/null +++ b/server/routers/launcher/types.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; + +export const launcherViewConfigSchema = z.object({ + groupBy: z.enum(["site", "label"]).default("site"), + layout: z.enum(["grid", "list"]).default("grid"), + sortBy: z.literal("name").default("name"), + order: z.enum(["asc", "desc"]).default("asc"), + showLabels: z.boolean().default(true), + showSiteTags: z.boolean().default(true), + showRecents: z.boolean().default(false).optional(), + siteIds: z.array(z.number()).default([]), + labelIds: z.array(z.number()).default([]), + query: z.string().default("") +}); + +export type LauncherViewConfig = z.infer; + +export const defaultLauncherViewConfig: LauncherViewConfig = + launcherViewConfigSchema.parse({}); + +export type LauncherLabel = { + labelId: number; + name: string; + color: string; +}; + +export type LauncherSiteInfo = { + siteId: number; + name: string; + type: string; + online?: boolean; +}; + +export type LauncherResource = { + launcherResourceKey: string; + resourceType: "public" | "site"; + resourceId: number; + siteResourceId?: number; + name: string; + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; + iconUrl: string | null; + enabled: boolean; + mode: string; + labels: LauncherLabel[]; + site?: LauncherSiteInfo; +}; + +export type LauncherGroup = { + groupKey: string; + name: string; + groupType: "site" | "label"; + itemCount: number; + siteType?: string; + siteOnline?: boolean; + labelColor?: string; +}; + +export type ListLauncherGroupsResponse = { + groups: LauncherGroup[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherResourcesResponse = { + resources: LauncherResource[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type LauncherViewRecord = { + viewId: number; + orgId: string; + userId: string | null; + name: string; + config: LauncherViewConfig; + createdAt: string; + updatedAt: string; + isOrgWide: boolean; +}; + +export type ListLauncherViewsResponse = { + views: LauncherViewRecord[]; +}; + +export const launcherListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(20) + .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"), + groupKey: z.string().optional(), + 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 LauncherListQuery = z.infer; + +export function parseIdListParam(value: string | undefined): number[] { + if (!value?.trim()) { + return []; + } + return value + .split(",") + .map((part) => Number.parseInt(part.trim(), 10)) + .filter((id) => Number.isFinite(id)); +} + +export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const; + +export type LauncherViewSelection = + | { type: "default" } + | { type: "saved"; viewId: number }; diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts new file mode 100644 index 000000000..2db865952 --- /dev/null +++ b/server/routers/launcher/updateLauncherView.ts @@ -0,0 +1,164 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import { getFirstString } from "@server/lib/requestParams"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import moment from "moment"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { + isOrgAdminOrOwner, + verifyLauncherOrgMembership +} from "./launcherResourceAccess"; +import { launcherViewConfigSchema } from "./types"; + +const updateLauncherViewBodySchema = z.strictObject({ + name: z.string().min(1).max(128).optional(), + config: launcherViewConfigSchema.optional(), + orgWide: z.boolean().optional() +}); + +export async function updateLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = getFirstString(req.params.orgId); + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + const parsed = updateLauncherViewBodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { userRoleIds } = await verifyLauncherOrgMembership( + orgId, + userId + ); + + const [existing] = await db + .select() + .from(launcherViews) + .where( + and( + eq(launcherViews.viewId, viewId), + eq(launcherViews.orgId, orgId) + ) + ) + .limit(1); + + if (!existing) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Launcher view not found") + ); + } + + const isPersonalView = existing.userId === userId; + const isOrgWideView = existing.userId == null; + const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + + if (!isPersonalView && !(isOrgWideView && isAdmin)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to update this view" + ) + ); + } + + if (parsed.data.orgWide === true && !isAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can make views org-wide" + ) + ); + } + + if (parsed.data.orgWide === false && isOrgWideView && !isAdmin) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only administrators can change org-wide views" + ) + ); + } + + const nextUserId = + parsed.data.orgWide === true + ? null + : parsed.data.orgWide === false + ? userId + : existing.userId; + + const [updated] = await db + .update(launcherViews) + .set({ + name: parsed.data.name ?? existing.name, + config: parsed.data.config + ? JSON.stringify(parsed.data.config) + : existing.config, + userId: nextUserId, + updatedAt: moment().toISOString() + }) + .where(eq(launcherViews.viewId, viewId)) + .returning(); + + return response(res, { + data: { + viewId: updated.viewId, + orgId: updated.orgId, + userId: updated.userId, + name: updated.name, + config: launcherViewConfigSchema.parse( + JSON.parse(updated.config) + ), + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + isOrgWide: updated.userId == null + }, + success: true, + error: false, + message: "Launcher view updated successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error updating launcher 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 fc806acc1..0c3ad6547 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,5 +1,5 @@ import { Layout } from "@app/components/Layout"; -import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; @@ -18,7 +18,6 @@ type OrgPageProps = { export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; - const env = pullEnv(); if (!orgId) { redirect(`/`); @@ -40,12 +39,6 @@ export default async function OrgPage(props: OrgPageProps) { overview = res.data.data; } catch (e) {} - // If user is admin or owner, redirect to settings - if (overview?.isAdmin || overview?.isOwner) { - redirect(`/${orgId}/settings`); - } - - // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => @@ -60,10 +53,21 @@ export default async function OrgPage(props: OrgPageProps) { } } catch (e) {} + const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + return ( - - {overview && } + + {overview ? ( + + ) : null} ); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..5657366f8 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -16,6 +16,8 @@ interface LayoutProps { showHeader?: boolean; showTopBar?: boolean; defaultSidebarCollapsed?: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export async function Layout({ @@ -26,7 +28,9 @@ export async function Layout({ showSidebar = true, showHeader = true, showTopBar = true, - defaultSidebarCollapsed = false + defaultSidebarCollapsed = false, + launcherMode = false, + showViewAsAdmin = false }: LayoutProps) { const allCookies = await cookies(); const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value; @@ -68,7 +72,15 @@ export async function Layout({ )} {/* Desktop header */} - {showHeader && } + {showHeader && ( + + )} {/* Main content */}
diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx index 29850f115..434963882 100644 --- a/src/components/LayoutHeader.tsx +++ b/src/components/LayoutHeader.tsx @@ -8,16 +8,31 @@ import { useTheme } from "next-themes"; import BrandingLogo from "./BrandingLogo"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { LauncherOrgSelector } from "@app/components/resource-launcher/LauncherOrgSelector"; +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; -interface LayoutHeaderProps { +type LayoutHeaderProps = { showTopBar: boolean; -} + launcherMode?: boolean; + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + showViewAsAdmin?: boolean; +}; -export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { +export function LayoutHeader({ + showTopBar, + launcherMode = false, + orgId, + orgs, + showViewAsAdmin = false +}: LayoutHeaderProps) { const { theme } = useTheme(); const [path, setPath] = useState(""); const { env } = useEnvContext(); const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); const logoWidth = isUnlocked() ? env.branding.logo?.navbar?.width || 98 @@ -53,16 +68,38 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
-
- +
+ - {/* {build === "saas" && ( - Cloud Beta - )} */} + {launcherMode ? ( + <> + + {showViewAsAdmin && orgId ? ( + + ) : null} + + ) : null}
{showTopBar && ( diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index a7c3a141f..a1a7bc412 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -18,7 +18,13 @@ import { approvalQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { useQuery } from "@tanstack/react-query"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react"; +import { + ArrowRight, + ExternalLink, + PanelRightOpen, + Server, + SquareMousePointer +} from "lucide-react"; import { useTranslations } from "next-intl"; import dynamic from "next/dynamic"; import Link from "next/link"; @@ -130,6 +136,13 @@ export function LayoutSidebar({ const showTrial = build === "saas" && Boolean(orgId) && subscriptionContext?.isTrial; + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + return (
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + + + + {!isSidebarCollapsed && ( + + {t("resourceLauncherTitle")} + + )} + +
+ )} {!isAdminPage && user.serverAdmin && (
{ - const [faviconError, setFaviconError] = useState(false); - const [faviconLoaded, setFaviconLoaded] = useState(false); - - // Extract domain for favicon URL - const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0]; - const faviconUrl = `https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=32`; - - const handleFaviconLoad = () => { - setFaviconLoaded(true); - setFaviconError(false); - }; - - const handleFaviconError = () => { - setFaviconError(true); - setFaviconLoaded(false); - }; - - if (faviconError || !enabled) { - return ( - - ); - } - - return ( -
- {!faviconLoaded && ( -
- )} - {`${cleanDomain} -
- ); -}; - -// Resource Info component -const ResourceInfo = ({ resource }: { resource: Resource }) => { - const t = useTranslations(); - const hasAuthMethods = - resource.sso || - resource.password || - resource.pincode || - resource.whitelist; - - const hasAnyInfo = - Boolean(resource.siteName) || - Boolean(hasAuthMethods) || - !resource.enabled; - - if (!hasAnyInfo) return null; - - const infoContent = ( -
- {/* Site Information */} - {resource.siteName && ( -
-
- {t("site")} -
-
- - {resource.siteName} -
-
- )} - - {/* Authentication Methods */} - {hasAuthMethods && ( -
-
- {t("memberPortalAuthMethods")} -
-
- {resource.sso && ( -
-
- -
- - {t("memberPortalSso")} - -
- )} - {resource.password && ( -
-
- -
- - {t("memberPortalPasswordProtected")} - -
- )} - {resource.pincode && ( -
-
- -
- - {t("memberPortalPinCode")} - -
- )} - {resource.whitelist && ( -
-
- -
- - {t("memberPortalEmailWhitelist")} - -
- )} -
-
- )} - - {/* Resource Status - if disabled */} - {!resource.enabled && ( -
-
- - - {t("memberPortalResourceDisabled")} - -
-
- )} -
- ); - - return {infoContent}; -}; - -// Pagination component -const PaginationControls = ({ - currentPage, - totalPages, - onPageChange, - totalItems, - itemsPerPage -}: { - currentPage: number; - totalPages: number; - onPageChange: (page: number) => void; - totalItems: number; - itemsPerPage: number; -}) => { - const t = useTranslations(); - const startItem = (currentPage - 1) * itemsPerPage + 1; - const endItem = Math.min(currentPage * itemsPerPage, totalItems); - - if (totalPages <= 1) return null; - - return ( -
-
- {t("memberPortalShowingResources", { - start: startItem, - end: endItem, - total: totalItems - })} -
- -
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map( - (page) => { - // Show first page, last page, current page, and 2 pages around current - const showPage = - page === 1 || - page === totalPages || - Math.abs(page - currentPage) <= 1; - - const showEllipsis = - (page === 2 && currentPage > 4) || - (page === totalPages - 1 && - currentPage < totalPages - 3); - - if (!showPage && !showEllipsis) return null; - - if (showEllipsis) { - return ( - - ... - - ); - } - - return ( - - ); - } - )} -
- - -
-
- ); -}; - -// Loading skeleton component -const ResourceCardSkeleton = () => ( - - -
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-); - -export default function MemberResourcesPortal({ - orgId -}: MemberResourcesPortalProps) { - const t = useTranslations(); - const { env } = useEnvContext(); - const api = createApiClient({ env }); - const { toast } = useToast(); - - const [resources, setResources] = useState([]); - const [siteResources, setSiteResources] = useState([]); - const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState< - SiteResource[] - >([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("name-asc"); - const [refreshing, setRefreshing] = useState(false); - - // Pagination state - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 12; // 3x4 grid on desktop - - const fetchUserResources = async (isRefresh = false) => { - try { - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } - setError(null); - - const response = await api.get( - `/org/${orgId}/user-resources` - ); - - if (response.data.success) { - setResources(response.data.data.resources); - setSiteResources(response.data.data.siteResources || []); - setFilteredResources(response.data.data.resources); - setFilteredSiteResources( - response.data.data.siteResources || [] - ); - } else { - setError(t("memberPortalFailedToLoad")); - } - } catch (err) { - console.error("Error fetching user resources:", err); - setError(t("memberPortalFailedToLoadDescription")); - } finally { - setLoading(false); - setRefreshing(false); - } - }; - - useEffect(() => { - fetchUserResources(); - }, [orgId, api]); - - // Filter and sort resources - useEffect(() => { - const filtered = resources.filter( - (resource) => - resource.name - .toLowerCase() - .includes(searchQuery.toLowerCase()) || - resource.domain - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - - // Sort resources - filtered.sort((a, b) => { - switch (sortBy) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "domain-asc": - return a.domain.localeCompare(b.domain); - case "domain-desc": - return b.domain.localeCompare(a.domain); - case "status-enabled": - // Enabled first, then protected vs unprotected - if (a.enabled !== b.enabled) return b.enabled ? 1 : -1; - return b.protected ? 1 : -1; - case "status-disabled": - // Disabled first, then unprotected vs protected - if (a.enabled !== b.enabled) return a.enabled ? 1 : -1; - return a.protected ? 1 : -1; - default: - return a.name.localeCompare(b.name); - } - }); - - setFilteredResources(filtered); - - // Filter and sort site resources - const filteredSites = siteResources.filter( - (resource) => - resource.name - .toLowerCase() - .includes(searchQuery.toLowerCase()) || - resource.destination - .toLowerCase() - .includes(searchQuery.toLowerCase()) - ); - - // Sort site resources - filteredSites.sort((a, b) => { - switch (sortBy) { - case "name-asc": - return a.name.localeCompare(b.name); - case "name-desc": - return b.name.localeCompare(a.name); - case "domain-asc": - case "domain-desc": - // Sort by destination for site resources - const destCompare = - sortBy === "domain-asc" - ? a.destination.localeCompare(b.destination) - : b.destination.localeCompare(a.destination); - return destCompare; - case "status-enabled": - return b.enabled ? 1 : -1; - case "status-disabled": - return a.enabled ? 1 : -1; - default: - return a.name.localeCompare(b.name); - } - }); - - setFilteredSiteResources(filteredSites); - - // Reset to first page when search/sort changes - setCurrentPage(1); - }, [resources, siteResources, searchQuery, sortBy]); - - // Calculate pagination - const totalItems = filteredResources.length + filteredSiteResources.length; - const totalPages = Math.ceil(totalItems / itemsPerPage); - const startIndex = (currentPage - 1) * itemsPerPage; - const paginatedResources = filteredResources.slice( - startIndex, - startIndex + itemsPerPage - ); - const remainingSlots = itemsPerPage - paginatedResources.length; - const paginatedSiteResources = - remainingSlots > 0 - ? filteredSiteResources.slice( - Math.max(0, startIndex - filteredResources.length), - Math.max(0, startIndex - filteredResources.length) + - remainingSlots - ) - : []; - - const handleOpenResource = (resource: Resource) => { - // Open the resource in a new tab - window.open(resource.domain, "_blank"); - }; - - const handleRefresh = () => { - fetchUserResources(true); - }; - - const handleRetry = () => { - fetchUserResources(); - }; - - const handlePageChange = (page: number) => { - setCurrentPage(page); - // Scroll to top when page changes - window.scrollTo({ top: 0, behavior: "smooth" }); - }; - - if (loading) { - return ( -
- - - {/* Search and Sort Controls - Skeleton */} -
-
-
-
-
-
-
-
- - {/* Loading Skeletons */} -
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
-
- ); - } - - if (error) { - return ( -
- - - -
- -
-

- {t("memberPortalUnableToLoad")} -

-

- {error} -

- -
-
-
- ); - } - - return ( -
- - - {/* Search and Sort Controls with Refresh */} -
-
- {/* Search */} -
- setSearchQuery(e.target.value)} - className="w-full pl-8 bg-card" - /> - -
- - {/* Sort */} -
- -
-
- - {/* Refresh Button */} - -
- - {/* Resources Content */} - {filteredResources.length === 0 && - filteredSiteResources.length === 0 ? ( - /* Enhanced Empty State */ - - -
- {searchQuery ? ( - - ) : ( - - )} -
-

- {searchQuery - ? t("memberPortalNoResourcesFound") - : t("memberPortalNoResourcesAvailable")} -

-

- {searchQuery - ? t("memberPortalNoResourcesMatchSearch", { - query: searchQuery - }) - : t("memberPortalNoResourcesAccess")} -

-
- {searchQuery ? ( - - ) : ( - - )} -
-
-
- ) : ( - <> - {/* Public Resources Section */} - {paginatedResources.length > 0 && ( - <> -
-

- - {t("memberPortalPublicResources")} -

-

- {t( - "memberPortalPublicResourcesDescription" - )} -

-
-
- {paginatedResources.map((resource) => ( - -
-
-
- - - - - { - resource.name - } - - - -

- { - resource.name - } -

-
-
-
-
- -
- - {resource.mode.toUpperCase()} - - -
-
- -
- - -
-
- -
- -
-
- ))} -
- - )} - - {/* Private Resources (Site Resources) Section */} - {paginatedSiteResources.length > 0 && ( - <> -
-

- - {t("memberPortalPrivateResources")} -

-

- {t( - "memberPortalPrivateResourcesDescription" - )} -

-
-
- {paginatedSiteResources.map((siteResource) => ( - -
-
-
- - - - - { - siteResource.name - } - - - -

- { - siteResource.name - } -

-
-
-
-
- -
- - {siteResource.mode.toUpperCase()} - - -
-
- {t( - "memberPortalResourceDetails" - )} -
-
- - {t( - "memberPortalMode" - )} - : - - - {siteResource.mode.toUpperCase()} - -
- {siteResource.destination && ( -
- - {t( - "memberPortalDestination" - )} - : - - - { - siteResource.destination - } - -
- )} - {siteResource.alias && ( -
- - {t( - "memberPortalAlias" - )} - : - - - { - siteResource.alias - } - -
- )} -
- - {t( - "status" - )} - : - - - {siteResource.enabled - ? t( - "enabled" - ) - : t( - "disabled" - )} - -
-
-
-
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - /* HTTP mode - show as clickable link */ - - ) : siteResource.alias ? ( - /* Alias as primary */ -
-
- {siteResource.alias} -
- -
- ) : siteResource.destination ? ( - /* Destination as primary when no alias */ -
-
- { - siteResource.destination - } -
- -
- ) : ( - /* niceId fallback when no alias and no destination */ -
-
- { - siteResource.niceId - } -
- -
- )} -
-
- -
- {siteResource.mode === "http" && - siteResource.fullDomain ? ( - - ) : null} -
- - {t( - "memberPortalRequiresClientConnection" - )} -
-
-
- ))} -
- - )} - - {/* Pagination Controls */} - - - )} -
- ); -} diff --git a/src/components/labels-selector.tsx b/src/components/labels-selector.tsx index 5cf7ce944..eda4bad1c 100644 --- a/src/components/labels-selector.tsx +++ b/src/components/labels-selector.tsx @@ -38,6 +38,21 @@ export type LabelsSelectorProps = { toggleLabel: (newlabel: SelectedLabel, action: "detach" | "attach") => void; }; +export function formatLabelsSelectorLabel( + selectedLabels: SelectedLabel[], + t: (key: string, values?: { count: number }) => string +): string { + if (selectedLabels.length === 0) { + return t("selectLabels"); + } + if (selectedLabels.length === 1) { + return selectedLabels[0]!.name; + } + return t("labelsSelectorLabelsCount", { + count: selectedLabels.length + }); +} + export const LABEL_COLORS = { red: "#ff6467", green: "#05df72", diff --git a/src/components/resource-launcher/LauncherCopyIcon.tsx b/src/components/resource-launcher/LauncherCopyIcon.tsx new file mode 100644 index 000000000..bfaf21d04 --- /dev/null +++ b/src/components/resource-launcher/LauncherCopyIcon.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import { Check, Copy } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; + +type LauncherCopyIconProps = { + text: string; + className?: string; +}; + +export function LauncherCopyIcon({ text, className }: LauncherCopyIconProps) { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + + if (!text) { + return null; + } + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx new file mode 100644 index 000000000..36d6a3baa --- /dev/null +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + formatMultiSitesSelectorLabel, + MultiSitesSelector +} from "@app/components/multi-site-selector"; +import { + formatLabelsSelectorLabel, + LabelsSelector, + type SelectedLabel +} from "@app/components/labels-selector"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; +import { ChevronsUpDown, Funnel } from "lucide-react"; +import { useState } from "react"; +import type { Selectedsite } from "@app/components/site-selector"; + +type LauncherFilterPopoverProps = { + orgId: string; + selectedSites: Selectedsite[]; + selectedLabels: SelectedLabel[]; + onSitesChange: (sites: Selectedsite[]) => void; + onLabelsChange: (labels: SelectedLabel[]) => void; +}; + +export function LauncherFilterPopover({ + orgId, + selectedSites, + selectedLabels, + onSitesChange, + onLabelsChange +}: LauncherFilterPopoverProps) { + const t = useTranslations(); + const [sitesOpen, setSitesOpen] = useState(false); + const [labelsOpen, setLabelsOpen] = useState(false); + + return ( + + + + + +
+
+

{t("sites")}

+ + + + + + + + +
+
+

{t("labels")}

+ + + + + + { + if (action === "attach") { + onLabelsChange([ + ...selectedLabels, + label + ]); + } else { + onLabelsChange( + selectedLabels.filter( + (item) => + item.labelId !== + label.labelId + ) + ); + } + }} + /> + + +
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx new file mode 100644 index 000000000..854241cdd --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -0,0 +1,201 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { LauncherGroupSection } from "./LauncherGroupSection"; + +type LauncherGroupListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + searchQuery: string; +}; + +function buildResourceFilters( + config: LauncherViewConfig, + searchQuery: string, + groupKey: string +) { + return { + query: searchQuery, + groupBy: config.groupBy, + groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }; +} + +export function LauncherGroupList({ + orgId, + activeViewId, + config, + searchQuery +}: LauncherGroupListProps) { + const queryClient = useQueryClient(); + const loadMoreRef = useRef(null); + const [isPrefetching, setIsPrefetching] = useState(false); + const prefetchBatchKeyRef = useRef(null); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.groups(orgId, { + query: searchQuery, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }) + }); + + const groups = data?.pages.flatMap((page) => page.groups) ?? []; + + const batchKey = useMemo( + () => + JSON.stringify({ + activeViewId, + searchQuery, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sortBy: config.sortBy, + order: config.order + }), + [ + activeViewId, + config.groupBy, + config.labelIds, + config.order, + config.siteIds, + config.sortBy, + searchQuery + ] + ); + + const openGroupKeys = useMemo( + () => + groups + .filter((group) => + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + true + ) + ) + .map((group) => group.groupKey), + [activeViewId, config.groupBy, groups, orgId] + ); + + useEffect(() => { + if (isLoading) { + return; + } + + if (openGroupKeys.length === 0) { + prefetchBatchKeyRef.current = batchKey; + setIsPrefetching(false); + return; + } + + if (prefetchBatchKeyRef.current === batchKey) { + return; + } + + let cancelled = false; + setIsPrefetching(true); + + void Promise.all( + openGroupKeys.map((groupKey) => + queryClient.prefetchInfiniteQuery( + launcherQueries.resources( + orgId, + buildResourceFilters(config, searchQuery, groupKey) + ) + ) + ) + ).finally(() => { + if (!cancelled) { + prefetchBatchKeyRef.current = batchKey; + setIsPrefetching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [ + batchKey, + config, + isLoading, + openGroupKeys, + orgId, + queryClient, + searchQuery + ]); + + const isBatchPending = prefetchBatchKeyRef.current !== batchKey; + const isBodyLoading = + isLoading || + (isBatchPending && + openGroupKeys.length > 0 && + (isPrefetching || !isLoading)); + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage || isBodyLoading) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]); + + if (isBodyLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {groups.map((group) => ( + + ))} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx new file mode 100644 index 000000000..0f174bd86 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { + Collapsible, + CollapsibleContent +} from "@app/components/ui/collapsible"; +import { cn } from "@app/lib/cn"; +import { + readLauncherGroupOpen, + writeLauncherGroupOpen, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { launcherQueries } from "@app/lib/queries"; +import type { + LauncherGroup, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useRef, useState } from "react"; +import { LauncherGroupTrigger } from "./LauncherGroupTrigger"; +import { LauncherResourceGrid } from "./LauncherResourceGrid"; +import { LauncherResourceList } from "./LauncherResourceList"; + +type LauncherGroupSectionProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + group: LauncherGroup; + config: LauncherViewConfig; + searchQuery: string; + defaultOpen?: boolean; +}; + +export function LauncherGroupSection({ + orgId, + activeViewId, + group, + config, + searchQuery, + defaultOpen = true +}: LauncherGroupSectionProps) { + const t = useTranslations(); + const loadMoreRef = useRef(null); + const [isOpen, setIsOpen] = useState(() => + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + + useEffect(() => { + setIsOpen( + readLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + defaultOpen + ) + ); + }, [activeViewId, config.groupBy, defaultOpen, group.groupKey, orgId]); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + writeLauncherGroupOpen( + orgId, + activeViewId, + config.groupBy, + group.groupKey, + open + ); + }; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, { + query: searchQuery, + groupBy: config.groupBy, + groupKey: group.groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + enabled: isOpen + }); + + 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]); + + const groupTitle = + group.groupKey === "unlabeled" + ? t("resourceLauncherUnlabeled") + : group.name; + + return ( + + + + + {isLoading ? ( +
+ +
+ ) : resources.length === 0 ? ( +

+ {t("resourceLauncherNoResourcesInGroup")} +

+ ) : config.layout === "grid" ? ( + + ) : ( + + )} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} + + + ); +} diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx new file mode 100644 index 000000000..a43536fd5 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { CollapsibleTrigger } from "@app/components/ui/collapsible"; +import type { LauncherGroup } from "@server/routers/launcher/types"; +import { ChevronDown, ChevronLeft } from "lucide-react"; + +type LauncherGroupTriggerProps = { + group: LauncherGroup; + title: string; + isOpen: boolean; +}; + +function LauncherGroupStatusDot({ group }: { group: LauncherGroup }) { + if (group.groupType === "label") { + return ( + + ); + } + + if (group.groupType === "site") { + if ( + (group.siteType === "newt" || group.siteType === "wireguard") && + typeof group.siteOnline === "boolean" + ) { + return ( + + ); + } + + return ; + } + + return null; +} + +export function LauncherGroupTrigger({ + group, + title, + isOpen +}: LauncherGroupTriggerProps) { + return ( + + {group.groupType === "site" || group.groupType === "label" ? ( + + ) : null} + + + {title} ({group.itemCount}) + + {isOpen ? ( + + ) : ( + + )} + + + ); +} diff --git a/src/components/resource-launcher/LauncherLabelsRow.tsx b/src/components/resource-launcher/LauncherLabelsRow.tsx new file mode 100644 index 000000000..e2c913ae9 --- /dev/null +++ b/src/components/resource-launcher/LauncherLabelsRow.tsx @@ -0,0 +1,175 @@ +"use client"; + +import type { LauncherLabel } from "@server/routers/launcher/types"; +import { LabelBadge } from "@app/components/label-badge"; +import { LabelOverflowBadge } from "@app/components/label-overflow-badge"; +import { cn } from "@app/lib/cn"; +import { useLayoutEffect, useRef, useState } from "react"; + +const MAX_LABEL_ROWS = 2; +const SINGLE_ROW_MAX_LABELS = 5; + +type LauncherLabelsRowProps = { + labels: LauncherLabel[]; + className?: string; + variant?: "wrap" | "single-row"; +}; + +function countFlexRows(container: HTMLElement): number { + const rowTops = new Set(); + + for (const child of container.children) { + const element = child as HTMLElement; + if (element.style.display === "none") { + continue; + } + rowTops.add(element.offsetTop); + } + + return rowTops.size; +} + +export function LauncherLabelsRow({ + labels, + className, + variant = "wrap" +}: LauncherLabelsRowProps) { + const containerRef = useRef(null); + const measureRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(labels.length); + + const labelKey = labels.map((label) => label.labelId).join(","); + + useLayoutEffect(() => { + if (variant === "single-row") { + return; + } + + const container = containerRef.current; + const measure = measureRef.current; + if (!container || !measure || labels.length === 0) { + return; + } + + const recompute = () => { + const width = container.clientWidth; + if (width <= 0) { + setVisibleCount(labels.length); + return; + } + + measure.style.width = `${width}px`; + + const labelNodes = measure.querySelectorAll( + "[data-measure-label]" + ); + const overflowNode = measure.querySelector( + "[data-measure-overflow]" + ); + + const fits = (visible: number) => { + labelNodes.forEach((node, index) => { + node.style.display = index < visible ? "" : "none"; + }); + + if (overflowNode) { + const overflowCount = labels.length - visible; + if (overflowCount > 0) { + overflowNode.style.display = ""; + } else { + overflowNode.style.display = "none"; + } + } + + return countFlexRows(measure) <= MAX_LABEL_ROWS; + }; + + let best = 0; + for (let visible = labels.length; visible >= 0; visible--) { + if (fits(visible)) { + best = visible; + break; + } + } + + setVisibleCount(best); + }; + + recompute(); + + const observer = new ResizeObserver(recompute); + observer.observe(container); + + return () => observer.disconnect(); + }, [labelKey, labels, variant]); + + if (labels.length === 0) { + return null; + } + + const resolvedVisibleCount = + variant === "single-row" + ? Math.min(labels.length, SINGLE_ROW_MAX_LABELS) + : visibleCount; + const visibleLabels = labels.slice(0, resolvedVisibleCount); + const overflowLabels = labels.slice(resolvedVisibleCount); + + return ( +
+
+ {visibleLabels.map((label) => ( + + ))} + {overflowLabels.length > 0 ? ( + ({ + color: label.color, + name: label.name + }))} + displayOnly + className="shrink-0" + /> + ) : null} +
+ + {variant === "wrap" ? ( +
+ {labels.map((label) => ( + + + + ))} + + + +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherOrgSelector.tsx b/src/components/resource-launcher/LauncherOrgSelector.tsx new file mode 100644 index 000000000..795e21927 --- /dev/null +++ b/src/components/resource-launcher/LauncherOrgSelector.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { Check, ChevronDown, ChevronsUpDown } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@app/components/ui/button"; + +type LauncherOrgSelectorProps = { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; +}; + +export function LauncherOrgSelector({ orgId, orgs }: LauncherOrgSelectorProps) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const pathname = usePathname(); + const t = useTranslations(); + + const selectedOrg = orgs?.find((org) => org.orgId === orgId); + + const sortedOrgs = useMemo(() => { + if (!orgs?.length) { + return orgs ?? []; + } + return [...orgs].sort((a, b) => { + const aPrimary = Boolean(a.isPrimaryOrg); + const bPrimary = Boolean(b.isPrimaryOrg); + if (aPrimary && !bPrimary) { + return -1; + } + if (!aPrimary && bPrimary) { + return 1; + } + return 0; + }); + }, [orgs]); + + return ( + + + + + + + + + {t("orgNotFound2")} + + {sortedOrgs.map((org) => ( + { + setOpen(false); + const newPath = pathname.includes( + "/settings/" + ) + ? pathname.replace( + /^\/[^/]+/, + `/${org.orgId}` + ) + : `/${org.orgId}`; + router.push(newPath); + }} + > +
+ + {org.name} + + + {org.orgId} + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx new file mode 100644 index 000000000..729036589 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import Link from "next/link"; +import { LauncherCopyIcon } from "./LauncherCopyIcon"; + +type LauncherResourceAccessProps = { + accessDisplay: string; + accessCopyValue: string; + accessUrl?: string | null; + variant: "grid" | "list"; +}; + +export function LauncherResourceAccess({ + accessDisplay, + accessCopyValue, + accessUrl, + variant +}: LauncherResourceAccessProps) { + if (!accessDisplay) { + return null; + } + + const href = accessUrl ?? undefined; + const canLink = href && isSafeUrlForLink(href); + const copyValue = canLink ? href : accessCopyValue; + + if (variant === "list") { + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); + } + + return ( +
+ {canLink ? ( + + {accessDisplay} + + ) : ( + + {accessDisplay} + + )} + +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx new file mode 100644 index 000000000..506893c29 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { + getLauncherResourceClickProps, + useLauncherResourceAction +} from "./useLauncherResourceAction"; + +type LauncherResourceCardProps = { + resource: LauncherResource; + showLabels: boolean; +}; + +export function LauncherResourceCard({ + resource, + showLabels +}: LauncherResourceCardProps) { + const hasIcon = Boolean(resource.iconUrl); + const { handleAction, isClickable } = useLauncherResourceAction({ + accessUrl: resource.accessUrl, + accessCopyValue: resource.accessCopyValue + }); + const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + + return ( +
+
+ {hasIcon ? ( + + ) : null} + +
+
+ {resource.name} +
+ +
+
+ + {showLabels && resource.labels.length > 0 ? ( + + ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceGrid.tsx b/src/components/resource-launcher/LauncherResourceGrid.tsx new file mode 100644 index 000000000..99b80a35e --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceGrid.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceCard } from "./LauncherResourceCard"; + +type LauncherResourceGridProps = { + resources: LauncherResource[]; + showLabels: boolean; +}; + +export function LauncherResourceGrid({ + resources, + showLabels +}: LauncherResourceGridProps) { + return ( +
+ {resources.map((resource) => ( + + ))} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceIcon.tsx b/src/components/resource-launcher/LauncherResourceIcon.tsx new file mode 100644 index 000000000..a4abbd63f --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceIcon.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { cn } from "@app/lib/cn"; + +type LauncherResourceIconProps = { + iconUrl?: string | null; + name: string; + className?: string; + variant?: "grid" | "list"; +}; + +export function LauncherResourceIcon({ + iconUrl, + name, + className, + variant = "grid" +}: LauncherResourceIconProps) { + const dimension = variant === "list" ? "size-5" : "size-10"; + + if (iconUrl) { + return ( + {name} + ); + } + + if (variant === "list") { + return ( +
+ - +
+ ); + } + + return null; +} diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx new file mode 100644 index 000000000..69d9a5385 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceRow } from "./LauncherResourceRow"; + +type LauncherResourceListProps = { + resources: LauncherResource[]; + showLabels: boolean; + showSiteTags: boolean; +}; + +export function LauncherResourceList({ + resources, + showLabels, + showSiteTags +}: LauncherResourceListProps) { + return ( +
+ {resources.map((resource, index) => ( + + ))} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx new file mode 100644 index 000000000..21dd763fe --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { LabelBadge } from "@app/components/label-badge"; +import { cn } from "@app/lib/cn"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherLabelsRow } from "./LauncherLabelsRow"; +import { LauncherResourceAccess } from "./LauncherResourceAccess"; +import { LauncherResourceIcon } from "./LauncherResourceIcon"; +import { + getLauncherResourceClickProps, + useLauncherResourceAction +} from "./useLauncherResourceAction"; + +type LauncherResourceRowProps = { + resource: LauncherResource; + showLabels: boolean; + showSiteTags: boolean; + isLast?: boolean; +}; + +export function LauncherResourceRow({ + resource, + showLabels, + showSiteTags, + isLast = false +}: LauncherResourceRowProps) { + const hasTags = + (showSiteTags && resource.site) || + (showLabels && resource.labels.length > 0); + const { handleAction, isClickable } = useLauncherResourceAction({ + accessUrl: resource.accessUrl, + accessCopyValue: resource.accessCopyValue + }); + const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + + return ( +
+ + + + {resource.name} + + + + + {hasTags ? ( +
+ {showSiteTags && resource.site ? ( + + ) : null} + {showLabels ? ( + + ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx new file mode 100644 index 000000000..086df395d --- /dev/null +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Label } from "@app/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; +import type { LauncherViewConfig } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import { Settings } from "lucide-react"; + +type LauncherSettingsMenuProps = { + config: LauncherViewConfig; + isDefaultView: boolean; + onConfigChange: (patch: Partial) => void; + onDeleteView: () => void; +}; + +export function LauncherSettingsMenu({ + config, + isDefaultView, + onConfigChange, + onDeleteView +}: LauncherSettingsMenuProps) { + const t = useTranslations(); + + return ( + + + + + +
+
+

+ {t("resourceLauncherGroupBy")} +

+ +
+ +
+

+ {t("resourceLauncherLayout")} +

+ +
+ +
+
+ + + onConfigChange({ showLabels: checked }) + } + /> +
+
+ + + onConfigChange({ showSiteTags: checked }) + } + /> +
+
+ + {!isDefaultView ? ( + + ) : null} +
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherSortButton.tsx b/src/components/resource-launcher/LauncherSortButton.tsx new file mode 100644 index 000000000..d79c3c6c2 --- /dev/null +++ b/src/components/resource-launcher/LauncherSortButton.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { ArrowDown01, ArrowUp10 } from "lucide-react"; + +type LauncherSortButtonProps = { + order: "asc" | "desc"; + onToggle: () => void; +}; + +export function LauncherSortButton({ + order, + onToggle +}: LauncherSortButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx new file mode 100644 index 000000000..474d04e67 --- /dev/null +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { useTranslations } from "next-intl"; +import { ChevronDown } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +type LauncherViewTabsProps = { + activeViewId: number | "default"; + savedViews: Array<{ viewId: number; name: string }>; + onSelectView: (viewId: number | "default") => void; +}; + +export function LauncherViewTabs({ + activeViewId, + savedViews, + onSelectView +}: LauncherViewTabsProps) { + const t = useTranslations(); + + const viewOptions: Array<{ + value: number | "default"; + label: string; + }> = [ + { value: "default", label: t("resourceLauncherDefaultView") }, + ...savedViews.map((view) => ({ + value: view.viewId, + label: view.name + })) + ]; + + return ( +
+ {viewOptions.map((option) => { + const isSelected = activeViewId === option.value; + return ( + + ); + })} +
+ ); +} + +type LauncherSaveViewMenuProps = { + isDefaultView: boolean; + isAdmin: boolean; + isOrgWideView: boolean; + hasUnsavedChanges: boolean; + onSaveToCurrent: () => void; + onSaveAsNew: () => void; + onSaveForEveryone: () => void; + onMakePersonal: () => void; +}; + +export function LauncherSaveViewMenu({ + isDefaultView, + isAdmin, + isOrgWideView, + hasUnsavedChanges, + onSaveToCurrent, + onSaveAsNew, + onSaveForEveryone, + onMakePersonal +}: LauncherSaveViewMenuProps) { + const t = useTranslations(); + + return ( + + + + + + {!isDefaultView ? ( + + {t("resourceLauncherSaveToCurrentView")} + + ) : null} + + {t("resourceLauncherSaveAsNewView")} + + {isAdmin && !isDefaultView && !isOrgWideView ? ( + + {t("resourceLauncherSaveForEveryone")} + + ) : null} + {isAdmin && !isDefaultView && isOrgWideView ? ( + + {t("resourceLauncherMakePersonal")} + + ) : null} + + + ); +} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx new file mode 100644 index 000000000..de73b7035 --- /dev/null +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Button } from "@app/components/ui/button"; +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Input } from "@app/components/ui/input"; +import { Label } from "@app/components/ui/label"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + readLauncherLastView, + writeLauncherLastView, + type LauncherActiveViewId +} from "@app/lib/launcherLocalStorage"; +import { + buildLauncherPath, + getLauncherUrlBaseConfig, + isLauncherConfigEqual, + resolveLauncherStateFromUrl, + serializeLauncherUrlState +} from "@app/lib/launcherUrlState"; +import { launcherQueries } from "@app/lib/queries"; +import { useToast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { + defaultLauncherViewConfig, + type LauncherViewConfig, + type LauncherViewRecord +} from "@server/routers/launcher/types"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDebouncedCallback } from "use-debounce"; +import type { Selectedsite } from "@app/components/site-selector"; +import type { SelectedLabel } from "@app/components/labels-selector"; +import { LauncherFilterPopover } from "./LauncherFilterPopover"; +import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; +import { LauncherSortButton } from "./LauncherSortButton"; +import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +type ResourceLauncherProps = { + orgId: string; + isAdmin: boolean; +}; + +export default function ResourceLauncher({ + orgId, + isAdmin +}: ResourceLauncherProps) { + const t = useTranslations(); + const { toast } = useToast(); + const { env } = useEnvContext(); + const queryClient = useQueryClient(); + const api = createApiClient({ env }); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [activeViewId, setActiveViewId] = + useState("default"); + const hasRestoredLastView = useRef(false); + const isApplyingUrlRef = useRef(false); + + const [config, setConfig] = useState( + defaultLauncherViewConfig + ); + const [savedConfig, setSavedConfig] = useState( + defaultLauncherViewConfig + ); + const [searchInput, setSearchInput] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [newViewName, setNewViewName] = useState(""); + const [saveOrgWide, setSaveOrgWide] = useState(false); + + const configRef = useRef(config); + configRef.current = config; + const searchInputRef = useRef(searchInput); + searchInputRef.current = searchInput; + const activeViewIdRef = useRef(activeViewId); + activeViewIdRef.current = activeViewId; + + const { data: views = [], isLoading: viewsLoading } = useQuery( + launcherQueries.views(orgId) + ); + + const syncUrl = useCallback( + (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { + if (isApplyingUrlRef.current) { + return; + } + + const params = serializeLauncherUrlState({ + viewId, + config: nextConfig + }); + const path = buildLauncherPath(orgId, params); + router.replace(path, { scroll: false }); + }, + [orgId, router] + ); + + const debouncedSyncSearch = useDebouncedCallback( + (viewId: LauncherActiveViewId, query: string) => { + const nextConfig = { ...configRef.current, query }; + setSearchQuery(query); + syncUrl(viewId, nextConfig); + }, + 300 + ); + + useEffect(() => { + if (viewsLoading) { + return; + } + + let fallbackViewId: LauncherActiveViewId | null = null; + if (!hasRestoredLastView.current) { + hasRestoredLastView.current = true; + fallbackViewId = readLauncherLastView(orgId); + } + + isApplyingUrlRef.current = true; + const resolved = resolveLauncherStateFromUrl( + new URLSearchParams(searchParams), + views, + fallbackViewId + ); + + setActiveViewId(resolved.activeViewId); + setConfig(resolved.config); + setSavedConfig(resolved.savedConfig); + setSearchInput(resolved.config.query); + setSearchQuery(resolved.config.query); + isApplyingUrlRef.current = false; + }, [orgId, searchParams, views, viewsLoading]); + + const selectView = useCallback( + (viewId: LauncherActiveViewId) => { + writeLauncherLastView(orgId, viewId); + const baseConfig = getLauncherUrlBaseConfig(viewId, views); + syncUrl(viewId, baseConfig); + }, + [orgId, syncUrl, views] + ); + + const activeSavedView = useMemo( + () => + activeViewId === "default" + ? null + : views.find((view) => view.viewId === activeViewId), + [activeViewId, views] + ); + + const isDefaultView = activeViewId === "default"; + const isOrgWideView = Boolean(activeSavedView?.isOrgWide); + const hasUnsavedChanges = !isLauncherConfigEqual(config, savedConfig); + + const selectedSites: Selectedsite[] = useMemo( + () => + config.siteIds.map((siteId) => ({ + siteId, + name: String(siteId), + type: "newt" + })), + [config.siteIds] + ); + + const selectedLabels: SelectedLabel[] = useMemo( + () => + config.labelIds.map((labelId) => ({ + labelId, + name: String(labelId), + color: "#a1a1aa" + })), + [config.labelIds] + ); + + const invalidateLauncher = () => { + void queryClient.invalidateQueries({ + queryKey: ["ORG", orgId, "LAUNCHER"] + }); + }; + + const createViewMutation = useMutation({ + mutationFn: async (payload: { + name: string; + config: LauncherViewConfig; + orgWide: boolean; + }) => { + const res = await api.post(`/org/${orgId}/launcher/views`, payload); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + invalidateLauncher(); + writeLauncherLastView(orgId, view.viewId); + + isApplyingUrlRef.current = true; + setActiveViewId(view.viewId); + setConfig(view.config); + setSavedConfig(view.config); + setSearchInput(view.config.query); + setSearchQuery(view.config.query); + isApplyingUrlRef.current = false; + + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + router.replace(buildLauncherPath(orgId, params), { scroll: false }); + + setSaveDialogOpen(false); + setNewViewName(""); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const updateViewMutation = useMutation({ + mutationFn: async (payload: { + viewId: number; + name?: string; + config?: LauncherViewConfig; + orgWide?: boolean; + }) => { + const { viewId, ...body } = payload; + const res = await api.put( + `/org/${orgId}/launcher/views/${viewId}`, + body + ); + return res.data.data as LauncherViewRecord; + }, + onSuccess: (view) => { + invalidateLauncher(); + + isApplyingUrlRef.current = true; + setActiveViewId(view.viewId); + setConfig(view.config); + setSavedConfig(view.config); + setSearchInput(view.config.query); + setSearchQuery(view.config.query); + isApplyingUrlRef.current = false; + + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + router.replace(buildLauncherPath(orgId, params), { scroll: false }); + + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + 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}`); + }, + onSuccess: () => { + invalidateLauncher(); + selectView("default"); + toast({ + title: t("resourceLauncherViewDeleted"), + description: t("resourceLauncherViewDeletedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewDeleteFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewDeleteFailedDescription") + ) + }); + } + }); + + const applyConfigPatch = useCallback( + (patch: Partial) => { + const nextConfig = { + ...configRef.current, + ...patch, + query: searchInputRef.current + }; + syncUrl(activeViewIdRef.current, nextConfig); + }, + [syncUrl] + ); + + const handleSaveToCurrent = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + config + }); + }; + + const handleSaveAsNew = () => { + setSaveOrgWide(false); + setNewViewName(""); + setSaveDialogOpen(true); + }; + + const handleSaveForEveryone = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: true + }); + }; + + const handleMakePersonal = () => { + if (isDefaultView) { + return; + } + updateViewMutation.mutate({ + viewId: activeViewId, + orgWide: false + }); + }; + + const handleCreateView = () => { + if (!newViewName.trim()) { + return; + } + createViewMutation.mutate({ + name: newViewName.trim(), + config, + orgWide: saveOrgWide && isAdmin + }); + }; + + return ( +
+ + +
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedSyncSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ {!viewsLoading ? ( + ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> + ) : null} +
+
+ + + applyConfigPatch({ + siteIds: sites.map((site) => site.siteId) + }) + } + onLabelsChange={(labels) => + applyConfigPatch({ + labelIds: labels.map( + (label) => label.labelId + ) + }) + } + /> + + applyConfigPatch({ + order: + config.order === "asc" ? "desc" : "asc" + }) + } + /> + { + if (!isDefaultView) { + deleteViewMutation.mutate(activeViewId); + } + }} + /> +
+
+
+ + + + + + + + {t("resourceLauncherSaveAsNewView")} + + + {t("resourceLauncherSaveAsNewViewDescription")} + + + +
+ + + setNewViewName(event.target.value) + } + /> +
+ {isAdmin ? ( +
+ + setSaveOrgWide(checked === true) + } + /> +

+ {t( + "resourceLauncherSaveForEveryoneDescription" + )} +

+
+ ) : null} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts new file mode 100644 index 000000000..427ee64bf --- /dev/null +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useToast } from "@app/hooks/useToast"; +import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess"; +import { useTranslations } from "next-intl"; +import { useCallback, type KeyboardEvent, type MouseEvent } from "react"; + +type LauncherResourceActionInput = { + accessUrl?: string | null; + accessCopyValue: string; +}; + +export function useLauncherResourceAction({ + accessUrl, + accessCopyValue +}: LauncherResourceActionInput) { + const { toast } = useToast(); + const t = useTranslations(); + + const href = accessUrl ?? undefined; + const canLink = Boolean(href && isSafeUrlForLink(href)); + const isClickable = canLink || Boolean(accessCopyValue); + + const handleAction = useCallback(() => { + if (canLink && href) { + window.open(href, "_blank", "noopener,noreferrer"); + return; + } + + if (!accessCopyValue) { + return; + } + + void navigator.clipboard.writeText(accessCopyValue).then(() => { + toast({ + title: t("resourceLauncherCopiedToClipboard"), + description: t("resourceLauncherCopiedAccessDescription"), + duration: 2000 + }); + }); + }, [accessCopyValue, canLink, href, t, toast]); + + return { handleAction, isClickable }; +} + +export function isLauncherResourceInteractiveTarget( + target: EventTarget | null +): boolean { + if (!(target instanceof Element)) { + return false; + } + + return Boolean( + target.closest("a, button, [role='button'], input, textarea, select") + ); +} + +function handleLauncherResourceClick( + event: MouseEvent, + handleAction: () => void +) { + if (isLauncherResourceInteractiveTarget(event.target)) { + return; + } + + handleAction(); +} + +export function getLauncherResourceClickProps( + handleAction: () => void, + isClickable: boolean +) { + return { + onClick: (event: MouseEvent) => + handleLauncherResourceClick(event, handleAction), + className: isClickable ? "cursor-pointer" : undefined, + role: isClickable ? ("button" as const) : undefined, + tabIndex: isClickable ? 0 : undefined, + onKeyDown: isClickable + ? (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleAction(); + } + } + : undefined + }; +} diff --git a/src/lib/launcherLocalStorage.ts b/src/lib/launcherLocalStorage.ts new file mode 100644 index 000000000..6e1fb60a5 --- /dev/null +++ b/src/lib/launcherLocalStorage.ts @@ -0,0 +1,98 @@ +export type LauncherActiveViewId = number | "default"; + +const LAST_VIEW_PREFIX = "pangolin:launcher:last-view:"; +const GROUP_OPEN_PREFIX = "pangolin:launcher:group-open:"; + +function lastViewKey(orgId: string) { + return `${LAST_VIEW_PREFIX}${orgId}`; +} + +function groupOpenKey( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +) { + return `${GROUP_OPEN_PREFIX}${orgId}:${viewId}:${groupBy}`; +} + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") { + return fallback; + } + + try { + const raw = window.localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch (error) { + console.warn(`Error reading localStorage key "${key}":`, error); + return fallback; + } +} + +function writeJson(key: string, value: unknown) { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`Error writing localStorage key "${key}":`, error); + } +} + +export function readLauncherLastView( + orgId: string +): LauncherActiveViewId | null { + const value = readJson( + lastViewKey(orgId), + null + ); + if (value === "default" || typeof value === "number") { + return value; + } + return null; +} + +export function writeLauncherLastView( + orgId: string, + viewId: LauncherActiveViewId +) { + writeJson(lastViewKey(orgId), viewId); +} + +export function readLauncherGroupOpenState( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label" +): Record { + return readJson>( + groupOpenKey(orgId, viewId, groupBy), + {} + ); +} + +export function readLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + defaultOpen: boolean +): boolean { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + return groupKey in state ? state[groupKey] : defaultOpen; +} + +export function writeLauncherGroupOpen( + orgId: string, + viewId: LauncherActiveViewId, + groupBy: "site" | "label", + groupKey: string, + isOpen: boolean +) { + const state = readLauncherGroupOpenState(orgId, viewId, groupBy); + writeJson(groupOpenKey(orgId, viewId, groupBy), { + ...state, + [groupKey]: isOpen + }); +} diff --git a/src/lib/launcherResourceAccess.ts b/src/lib/launcherResourceAccess.ts new file mode 100644 index 000000000..d7dd888ac --- /dev/null +++ b/src/lib/launcherResourceAccess.ts @@ -0,0 +1,123 @@ +import { + formatSiteResourceDestinationDisplay, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export { + formatSiteResourceDestinationDisplay, + resolveHttpHttpsDisplayPort, + type SiteResourceDestinationInput +} from "./formatSiteResourceAccess"; + +export type PublicResourceAccessInput = { + mode: string; + fullDomain: string | null; + ssl: boolean; + proxyPort: number | null; + wildcard: boolean; +}; + +export type SiteResourceAccessInput = { + mode: string; + destination: string | null; + destinationPort: number | null; + scheme: "http" | "https" | null; + ssl: boolean; + fullDomain: string | null; + alias: string | null; + aliasAddress: string | null; +}; + +export type LauncherAccessFields = { + accessDisplay: string; + accessCopyValue: string; + accessUrl: string | null; +}; + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + const port = resource.proxyPort?.toString() ?? ""; + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + if (!resource.fullDomain) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: resource.wildcard ? null : url + }; +} + +export function formatSiteResourceAccess( + resource: SiteResourceAccessInput +): LauncherAccessFields { + if (resource.alias) { + return { + accessDisplay: resource.alias, + accessCopyValue: resource.alias, + accessUrl: null + }; + } + + if (resource.mode === "http" && resource.fullDomain) { + const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + return { + accessDisplay: url, + accessCopyValue: url, + accessUrl: url + }; + } + + const destination = formatSiteResourceDestinationDisplay({ + mode: resource.mode as SiteResourceDestinationInput["mode"], + destination: resource.destination, + destinationPort: resource.destinationPort, + scheme: resource.scheme + }); + + if (destination) { + return { + accessDisplay: destination, + accessCopyValue: destination, + accessUrl: resource.mode === "http" ? destination : null + }; + } + + if (resource.aliasAddress) { + return { + accessDisplay: resource.aliasAddress, + accessCopyValue: resource.aliasAddress, + accessUrl: null + }; + } + + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; +} + +export function isSafeUrlForLink(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/lib/launcherUrlState.ts b/src/lib/launcherUrlState.ts new file mode 100644 index 000000000..13f74218c --- /dev/null +++ b/src/lib/launcherUrlState.ts @@ -0,0 +1,292 @@ +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { + defaultLauncherViewConfig, + parseIdListParam, + type LauncherViewConfig, + type LauncherViewRecord +} from "@server/routers/launcher/types"; +import { z } from "zod"; + +const launcherUrlBooleanSchema = z + .enum(["0", "1"]) + .transform((value) => value === "1"); + +export type LauncherUrlConfigOverrides = Partial< + Pick< + LauncherViewConfig, + | "groupBy" + | "layout" + | "order" + | "showLabels" + | "showSiteTags" + | "siteIds" + | "labelIds" + | "query" + > +>; + +export type ParsedLauncherUrlState = { + viewId: LauncherActiveViewId | null; + configOverrides: LauncherUrlConfigOverrides; + hasAnyLauncherParams: boolean; +}; + +export type ResolvedLauncherState = { + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; +}; + +const LAUNCHER_CONFIG_PARAM_KEYS = [ + "query", + "groupBy", + "layout", + "order", + "showLabels", + "showSiteTags", + "siteIds", + "labelIds" +] as const; + +const LAUNCHER_URL_PARAM_KEYS = [ + "view", + ...LAUNCHER_CONFIG_PARAM_KEYS +] as const; + +export function hasLauncherConfigParams(searchParams: URLSearchParams) { + return LAUNCHER_CONFIG_PARAM_KEYS.some((key) => searchParams.has(key)); +} + +export function isLauncherConfigEqual( + a: LauncherViewConfig, + b: LauncherViewConfig +) { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function getLauncherUrlBaseConfig( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +): LauncherViewConfig { + if (viewId === "default") { + return defaultLauncherViewConfig; + } + + const savedView = views.find((view) => view.viewId === viewId); + return savedView?.config ?? defaultLauncherViewConfig; +} + +export function resolveLauncherConfig( + baseConfig: LauncherViewConfig, + overrides: LauncherUrlConfigOverrides +): LauncherViewConfig { + return { + ...baseConfig, + ...overrides, + sortBy: "name" + }; +} + +function parseViewParam(value: string | null): LauncherActiveViewId | null { + if (value === null) { + return null; + } + + if (value === "default") { + return "default"; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return "default"; + } + + return parsed; +} + +function parseConfigOverrides( + searchParams: URLSearchParams +): LauncherUrlConfigOverrides { + const overrides: LauncherUrlConfigOverrides = {}; + + const query = searchParams.get("query"); + if (query !== null) { + overrides.query = query; + } + + const groupBy = searchParams.get("groupBy"); + if (groupBy === "site" || groupBy === "label") { + overrides.groupBy = groupBy; + } + + const layout = searchParams.get("layout"); + if (layout === "grid" || layout === "list") { + overrides.layout = layout; + } + + const order = searchParams.get("order"); + if (order === "asc" || order === "desc") { + overrides.order = order; + } + + const showLabels = searchParams.get("showLabels"); + if (showLabels !== null) { + const parsed = launcherUrlBooleanSchema.safeParse(showLabels); + if (parsed.success) { + overrides.showLabels = parsed.data; + } + } + + const showSiteTags = searchParams.get("showSiteTags"); + if (showSiteTags !== null) { + const parsed = launcherUrlBooleanSchema.safeParse(showSiteTags); + if (parsed.success) { + overrides.showSiteTags = parsed.data; + } + } + + const siteIds = searchParams.get("siteIds"); + if (siteIds !== null) { + overrides.siteIds = parseIdListParam(siteIds); + } + + const labelIds = searchParams.get("labelIds"); + if (labelIds !== null) { + overrides.labelIds = parseIdListParam(labelIds); + } + + return overrides; +} + +export function parseLauncherUrlState( + searchParams: URLSearchParams +): ParsedLauncherUrlState { + const hasAnyLauncherParams = LAUNCHER_URL_PARAM_KEYS.some((key) => + searchParams.has(key) + ); + + return { + viewId: parseViewParam(searchParams.get("view")), + configOverrides: parseConfigOverrides(searchParams), + hasAnyLauncherParams + }; +} + +function isValidActiveViewId( + viewId: LauncherActiveViewId, + views: LauncherViewRecord[] +) { + return viewId === "default" || views.some((view) => view.viewId === viewId); +} + +export function resolveLauncherStateFromUrl( + searchParams: URLSearchParams, + views: LauncherViewRecord[], + fallbackViewId: LauncherActiveViewId | null +): ResolvedLauncherState { + const parsed = parseLauncherUrlState(searchParams); + + let activeViewId: LauncherActiveViewId = "default"; + + if (parsed.viewId !== null) { + activeViewId = isValidActiveViewId(parsed.viewId, views) + ? parsed.viewId + : "default"; + } else if (!parsed.hasAnyLauncherParams && fallbackViewId !== null) { + activeViewId = isValidActiveViewId(fallbackViewId, views) + ? fallbackViewId + : "default"; + } + + const savedConfig = getLauncherUrlBaseConfig(activeViewId, views); + + let config: LauncherViewConfig; + if (hasLauncherConfigParams(searchParams)) { + config = resolveLauncherConfig( + defaultLauncherViewConfig, + parsed.configOverrides + ); + } else if (activeViewId !== "default") { + config = savedConfig; + } else { + config = defaultLauncherViewConfig; + } + + return { + activeViewId, + config, + savedConfig + }; +} + +function idListsEqual(a: number[], b: number[]) { + if (a.length !== b.length) { + return false; + } + + return a.every((value, index) => value === b[index]); +} + +export function serializeLauncherUrlState({ + viewId, + config +}: { + viewId: LauncherActiveViewId; + config: LauncherViewConfig; +}): URLSearchParams { + const baseConfig = defaultLauncherViewConfig; + const params = new URLSearchParams(); + + if (viewId !== "default") { + params.set("view", String(viewId)); + } + + if (config.query !== baseConfig.query && config.query) { + params.set("query", config.query); + } else if (config.query !== baseConfig.query && !config.query) { + params.set("query", ""); + } + + if (config.groupBy !== baseConfig.groupBy) { + params.set("groupBy", config.groupBy); + } + + if (config.layout !== baseConfig.layout) { + params.set("layout", config.layout); + } + + if (config.order !== baseConfig.order) { + params.set("order", config.order); + } + + if (config.showLabels !== baseConfig.showLabels) { + params.set("showLabels", config.showLabels ? "1" : "0"); + } + + if (config.showSiteTags !== baseConfig.showSiteTags) { + params.set("showSiteTags", config.showSiteTags ? "1" : "0"); + } + + if (!idListsEqual(config.siteIds, baseConfig.siteIds)) { + if (config.siteIds.length > 0) { + params.set("siteIds", config.siteIds.join(",")); + } else { + params.set("siteIds", ""); + } + } + + if (!idListsEqual(config.labelIds, baseConfig.labelIds)) { + if (config.labelIds.length > 0) { + params.set("labelIds", config.labelIds.join(",")); + } else { + params.set("labelIds", ""); + } + } + + return params; +} + +export function buildLauncherPath(orgId: string, params: URLSearchParams) { + const query = params.toString(); + return query ? `/${orgId}?${query}` : `/${orgId}`; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index b8a50a908..4d1cdc75c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -46,6 +46,13 @@ import { ListHealthChecksResponse } from "@server/routers/healthChecks/types"; import { StatusHistoryResponse } from "@server/lib/statusHistory"; import type { ListResourcePoliciesResponse } from "@server/routers/resource/types"; import type { GetResourcePolicyResponse } from "@server/routers/policy"; +import type { + ListLauncherGroupsResponse, + ListLauncherResourcesResponse, + ListLauncherViewsResponse, + LauncherListQuery, + LauncherViewConfig +} from "@server/routers/launcher/types"; export type ProductUpdate = { link: string | null; @@ -1166,3 +1173,96 @@ export const domainQueries = { refetchInterval: durationToMs(10, "seconds") }) }; + +export type LauncherQueryFilters = { + query?: string; + groupBy?: LauncherListQuery["groupBy"]; + groupKey?: string; + siteIds?: number[]; + labelIds?: number[]; + sort_by?: LauncherListQuery["sort_by"]; + order?: LauncherListQuery["order"]; + pageSize?: number; +}; + +function launcherSearchParams(filters: LauncherQueryFilters, page: number) { + const sp = new URLSearchParams(); + sp.set("page", String(page)); + sp.set("pageSize", String(filters.pageSize ?? 20)); + if (filters.query) { + sp.set("query", filters.query); + } + if (filters.groupBy) { + sp.set("groupBy", filters.groupBy); + } + if (filters.groupKey) { + sp.set("groupKey", filters.groupKey); + } + if (filters.siteIds?.length) { + sp.set("siteIds", filters.siteIds.join(",")); + } + if (filters.labelIds?.length) { + sp.set("labelIds", filters.labelIds.join(",")); + } + if (filters.sort_by) { + sp.set("sort_by", filters.sort_by); + } + if (filters.order) { + sp.set("order", filters.order); + } + return sp; +} + +export const launcherQueries = { + views: (orgId: string) => + queryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "VIEWS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, { signal }); + return res.data.data.views; + } + }), + groups: (orgId: string, filters: LauncherQueryFilters) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = launcherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }), + resources: ( + orgId: string, + filters: LauncherQueryFilters & { groupKey: string } + ) => + infiniteQueryOptions({ + queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = launcherSearchParams(filters, pageParam); + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/resources?${sp.toString()}`, { + signal + }); + return res.data.data; + }, + initialPageParam: 1, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage) => { + const { page, pageSize, total } = lastPage.pagination; + const nextPage = page + 1; + return page * pageSize < total ? nextPage : undefined; + } + }) +}; From fed4ec42c4aa5cd6b91ec2885d8029a6c02229a5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:15:03 -0400 Subject: [PATCH 02/21] add server side fetching --- src/app/[orgId]/loading.tsx | 9 + src/app/[orgId]/page.tsx | 28 ++- .../resource-launcher/LauncherGroupList.tsx | 214 ++++++------------ .../LauncherGroupSection.tsx | 42 +++- .../resource-launcher/ResourceLauncher.tsx | 212 ++++++++--------- src/lib/launcherSearchParams.ts | 43 ++++ src/lib/launcherServerData.ts | 128 +++++++++++ src/lib/queries.ts | 48 +--- 8 files changed, 411 insertions(+), 313 deletions(-) create mode 100644 src/app/[orgId]/loading.tsx create mode 100644 src/lib/launcherSearchParams.ts create mode 100644 src/lib/launcherServerData.ts diff --git a/src/app/[orgId]/loading.tsx b/src/app/[orgId]/loading.tsx new file mode 100644 index 000000000..0f7ea0d31 --- /dev/null +++ b/src/app/[orgId]/loading.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function OrgPageLoading() { + return ( +
+ +
+ ); +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 0c3ad6547..b76b77b7b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -2,8 +2,8 @@ import { Layout } from "@app/components/Layout"; import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { fetchLauncherPageData } from "@app/lib/launcherServerData"; import { verifySession } from "@app/lib/auth/verifySession"; -import { pullEnv } from "@app/lib/pullEnv"; import UserProvider from "@app/providers/UserProvider"; import { ListUserOrgsResponse } from "@server/routers/org"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; @@ -13,8 +13,11 @@ import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; +export const dynamic = "force-dynamic"; + export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; @@ -55,6 +58,15 @@ export default async function OrgPage(props: OrgPageProps) { const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); + const searchParams = new URLSearchParams(await props.searchParams); + const launcherData = overview + ? await fetchLauncherPageData( + orgId, + searchParams, + await authCookieHeader() + ) + : null; + return ( - {overview ? ( - + {overview && launcherData ? ( + ) : null} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index 854241cdd..c90c3a7cc 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -1,159 +1,80 @@ "use client"; import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; -import { readLauncherGroupOpen } from "@app/lib/launcherLocalStorage"; +import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; -import type { LauncherViewConfig } from "@server/routers/launcher/types"; -import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import type { + LauncherGroup, + LauncherViewConfig +} from "@server/routers/launcher/types"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { LauncherGroupSection } from "./LauncherGroupSection"; type LauncherGroupListProps = { orgId: string; activeViewId: LauncherActiveViewId; config: LauncherViewConfig; - searchQuery: string; -}; - -function buildResourceFilters( - config: LauncherViewConfig, - searchQuery: string, - groupKey: string -) { - return { - query: searchQuery, - groupBy: config.groupBy, - groupKey, - siteIds: config.siteIds, - labelIds: config.labelIds, - sort_by: config.sortBy, - order: config.order, - pageSize: 20 + initialGroups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; }; -} + resourcesByGroupKey: Record; +}; export function LauncherGroupList({ orgId, activeViewId, config, - searchQuery + initialGroups, + groupsPagination, + resourcesByGroupKey }: LauncherGroupListProps) { - const queryClient = useQueryClient(); const loadMoreRef = useRef(null); - const [isPrefetching, setIsPrefetching] = useState(false); - const prefetchBatchKeyRef = useRef(null); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + const groupFilters = useMemo( + () => ({ + query: config.query, + groupBy: config.groupBy, + 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 } = useInfiniteQuery({ - ...launcherQueries.groups(orgId, { - query: searchQuery, - groupBy: config.groupBy, - siteIds: config.siteIds, - labelIds: config.labelIds, - sort_by: config.sortBy, - order: config.order, - pageSize: 20 - }) + ...launcherQueries.groups(orgId, groupFilters), + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false }); const groups = data?.pages.flatMap((page) => page.groups) ?? []; - const batchKey = useMemo( - () => - JSON.stringify({ - activeViewId, - searchQuery, - groupBy: config.groupBy, - siteIds: config.siteIds, - labelIds: config.labelIds, - sortBy: config.sortBy, - order: config.order - }), - [ - activeViewId, - config.groupBy, - config.labelIds, - config.order, - config.siteIds, - config.sortBy, - searchQuery - ] - ); - - const openGroupKeys = useMemo( - () => - groups - .filter((group) => - readLauncherGroupOpen( - orgId, - activeViewId, - config.groupBy, - group.groupKey, - true - ) - ) - .map((group) => group.groupKey), - [activeViewId, config.groupBy, groups, orgId] - ); - - useEffect(() => { - if (isLoading) { - return; - } - - if (openGroupKeys.length === 0) { - prefetchBatchKeyRef.current = batchKey; - setIsPrefetching(false); - return; - } - - if (prefetchBatchKeyRef.current === batchKey) { - return; - } - - let cancelled = false; - setIsPrefetching(true); - - void Promise.all( - openGroupKeys.map((groupKey) => - queryClient.prefetchInfiniteQuery( - launcherQueries.resources( - orgId, - buildResourceFilters(config, searchQuery, groupKey) - ) - ) - ) - ).finally(() => { - if (!cancelled) { - prefetchBatchKeyRef.current = batchKey; - setIsPrefetching(false); - } - }); - - return () => { - cancelled = true; - }; - }, [ - batchKey, - config, - isLoading, - openGroupKeys, - orgId, - queryClient, - searchQuery - ]); - - const isBatchPending = prefetchBatchKeyRef.current !== batchKey; - const isBodyLoading = - isLoading || - (isBatchPending && - openGroupKeys.length > 0 && - (isPrefetching || !isLoading)); - useEffect(() => { const node = loadMoreRef.current; - if (!node || !hasNextPage || isBodyLoading) { + if (!node || !hasNextPage) { return; } @@ -168,28 +89,25 @@ export function LauncherGroupList({ observer.observe(node); return () => observer.disconnect(); - }, [fetchNextPage, hasNextPage, isBodyLoading, isFetchingNextPage]); - - if (isBodyLoading) { - return ( -
- -
- ); - } + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); return (
- {groups.map((group) => ( - - ))} + {groups.map((group) => { + const groupResources = resourcesByGroupKey[group.groupKey]; + + return ( + + ); + })}
{isFetchingNextPage ? (
diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 0f174bd86..8c32d9074 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -13,6 +13,7 @@ import { import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -28,7 +29,12 @@ type LauncherGroupSectionProps = { activeViewId: LauncherActiveViewId; group: LauncherGroup; config: LauncherViewConfig; - searchQuery: string; + initialResources?: LauncherResource[]; + initialResourcesPagination?: { + total: number; + page: number; + pageSize: number; + }; defaultOpen?: boolean; }; @@ -37,7 +43,8 @@ export function LauncherGroupSection({ activeViewId, group, config, - searchQuery, + initialResources, + initialResourcesPagination, defaultOpen = true }: LauncherGroupSectionProps) { const t = useTranslations(); @@ -75,10 +82,12 @@ export function LauncherGroupSection({ ); }; + const hasInitialResources = initialResources !== undefined; + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteQuery({ ...launcherQueries.resources(orgId, { - query: searchQuery, + query: config.query, groupBy: config.groupBy, groupKey: group.groupKey, siteIds: config.siteIds, @@ -87,14 +96,33 @@ export function LauncherGroupSection({ order: config.order, pageSize: 20 }), - enabled: isOpen + enabled: isOpen, + refetchOnMount: false, + ...(hasInitialResources + ? { + initialData: { + pages: [ + { + resources: initialResources, + pagination: initialResourcesPagination ?? { + total: initialResources.length, + page: 1, + pageSize: 20 + } + } + ], + pageParams: [1] + } + } + : {}) }); const resources = data?.pages.flatMap((page) => page.resources) ?? []; + const showInitialLoader = isLoading && resources.length === 0; useEffect(() => { const node = loadMoreRef.current; - if (!node || !hasNextPage) { + if (!node || !hasNextPage || !isOpen) { return; } @@ -109,7 +137,7 @@ export function LauncherGroupSection({ observer.observe(node); return () => observer.disconnect(); - }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); const groupTitle = group.groupKey === "unlabeled" @@ -129,7 +157,7 @@ export function LauncherGroupSection({ /> - {isLoading ? ( + {showInitialLoader ? (
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index de73b7035..b09dea773 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -14,30 +14,31 @@ import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Input } from "@app/components/ui/input"; import { Label } from "@app/components/ui/label"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { readLauncherLastView, writeLauncherLastView, type LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { buildLauncherPath, getLauncherUrlBaseConfig, isLauncherConfigEqual, - resolveLauncherStateFromUrl, + parseLauncherUrlState, serializeLauncherUrlState } from "@app/lib/launcherUrlState"; -import { launcherQueries } from "@app/lib/queries"; import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { - defaultLauncherViewConfig, - type LauncherViewConfig, - type LauncherViewRecord +import type { + LauncherGroup, + LauncherViewConfig, + LauncherViewRecord } from "@server/routers/launcher/types"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; @@ -52,33 +53,39 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type ResourceLauncherProps = { orgId: string; isAdmin: boolean; + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + resourcesByGroupKey: Record; }; export default function ResourceLauncher({ orgId, - isAdmin + isAdmin, + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination, + resourcesByGroupKey }: ResourceLauncherProps) { const t = useTranslations(); const { toast } = useToast(); const { env } = useEnvContext(); - const queryClient = useQueryClient(); const api = createApiClient({ env }); const router = useRouter(); - const searchParams = useSearchParams(); - - const [activeViewId, setActiveViewId] = - useState("default"); + const { navigate, isNavigating, searchParams } = useNavigationContext(); const hasRestoredLastView = useRef(false); - const isApplyingUrlRef = useRef(false); - const [config, setConfig] = useState( - defaultLauncherViewConfig - ); - const [savedConfig, setSavedConfig] = useState( - defaultLauncherViewConfig - ); - const [searchInput, setSearchInput] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); + const [searchInput, setSearchInput] = useState(config.query); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -90,68 +97,66 @@ export default function ResourceLauncher({ const activeViewIdRef = useRef(activeViewId); activeViewIdRef.current = activeViewId; - const { data: views = [], isLoading: viewsLoading } = useQuery( - launcherQueries.views(orgId) - ); + useEffect(() => { + setSearchInput(config.query); + }, [config.query]); - const syncUrl = useCallback( + useEffect(() => { + if (hasRestoredLastView.current) { + return; + } + hasRestoredLastView.current = true; + + const parsed = parseLauncherUrlState(searchParams); + if (parsed.hasAnyLauncherParams) { + return; + } + + const lastView = readLauncherLastView(orgId); + if (lastView === null || lastView === activeViewId) { + return; + } + + const isValid = + lastView === "default" || + views.some((view) => view.viewId === lastView); + if (!isValid) { + return; + } + + const baseConfig = getLauncherUrlBaseConfig(lastView, views); + const params = serializeLauncherUrlState({ + viewId: lastView, + config: baseConfig + }); + navigate({ searchParams: params, replace: true }); + }, [activeViewId, navigate, orgId, searchParams, views]); + + const navigateToConfig = useCallback( (viewId: LauncherActiveViewId, nextConfig: LauncherViewConfig) => { - if (isApplyingUrlRef.current) { - return; - } - const params = serializeLauncherUrlState({ viewId, config: nextConfig }); - const path = buildLauncherPath(orgId, params); - router.replace(path, { scroll: false }); + navigate({ searchParams: params }); }, - [orgId, router] + [navigate] ); - const debouncedSyncSearch = useDebouncedCallback( + const debouncedNavigateSearch = useDebouncedCallback( (viewId: LauncherActiveViewId, query: string) => { - const nextConfig = { ...configRef.current, query }; - setSearchQuery(query); - syncUrl(viewId, nextConfig); + navigateToConfig(viewId, { ...configRef.current, query }); }, 300 ); - useEffect(() => { - if (viewsLoading) { - return; - } - - let fallbackViewId: LauncherActiveViewId | null = null; - if (!hasRestoredLastView.current) { - hasRestoredLastView.current = true; - fallbackViewId = readLauncherLastView(orgId); - } - - isApplyingUrlRef.current = true; - const resolved = resolveLauncherStateFromUrl( - new URLSearchParams(searchParams), - views, - fallbackViewId - ); - - setActiveViewId(resolved.activeViewId); - setConfig(resolved.config); - setSavedConfig(resolved.savedConfig); - setSearchInput(resolved.config.query); - setSearchQuery(resolved.config.query); - isApplyingUrlRef.current = false; - }, [orgId, searchParams, views, viewsLoading]); - const selectView = useCallback( (viewId: LauncherActiveViewId) => { writeLauncherLastView(orgId, viewId); const baseConfig = getLauncherUrlBaseConfig(viewId, views); - syncUrl(viewId, baseConfig); + navigateToConfig(viewId, baseConfig); }, - [orgId, syncUrl, views] + [navigateToConfig, orgId, views] ); const activeSavedView = useMemo( @@ -186,12 +191,6 @@ export default function ResourceLauncher({ [config.labelIds] ); - const invalidateLauncher = () => { - void queryClient.invalidateQueries({ - queryKey: ["ORG", orgId, "LAUNCHER"] - }); - }; - const createViewMutation = useMutation({ mutationFn: async (payload: { name: string; @@ -202,23 +201,13 @@ export default function ResourceLauncher({ return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { - invalidateLauncher(); writeLauncherLastView(orgId, view.viewId); - - isApplyingUrlRef.current = true; - setActiveViewId(view.viewId); - setConfig(view.config); - setSavedConfig(view.config); - setSearchInput(view.config.query); - setSearchQuery(view.config.query); - isApplyingUrlRef.current = false; - const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); - router.replace(buildLauncherPath(orgId, params), { scroll: false }); - + navigate({ searchParams: params, replace: true }); + router.refresh(); setSaveDialogOpen(false); setNewViewName(""); toast({ @@ -253,22 +242,12 @@ export default function ResourceLauncher({ return res.data.data as LauncherViewRecord; }, onSuccess: (view) => { - invalidateLauncher(); - - isApplyingUrlRef.current = true; - setActiveViewId(view.viewId); - setConfig(view.config); - setSavedConfig(view.config); - setSearchInput(view.config.query); - setSearchQuery(view.config.query); - isApplyingUrlRef.current = false; - const params = serializeLauncherUrlState({ viewId: view.viewId, config: view.config }); - router.replace(buildLauncherPath(orgId, params), { scroll: false }); - + navigate({ searchParams: params, replace: true }); + router.refresh(); toast({ title: t("resourceLauncherViewSaved"), description: t("resourceLauncherViewSavedDescription") @@ -291,8 +270,13 @@ export default function ResourceLauncher({ await api.delete(`/org/${orgId}/launcher/views/${viewId}`); }, onSuccess: () => { - invalidateLauncher(); - selectView("default"); + writeLauncherLastView(orgId, "default"); + const params = serializeLauncherUrlState({ + viewId: "default", + config: getLauncherUrlBaseConfig("default", views) + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); toast({ title: t("resourceLauncherViewDeleted"), description: t("resourceLauncherViewDeletedDescription") @@ -317,9 +301,9 @@ export default function ResourceLauncher({ ...patch, query: searchInputRef.current }; - syncUrl(activeViewIdRef.current, nextConfig); + navigateToConfig(activeViewIdRef.current, nextConfig); }, - [syncUrl] + [navigateToConfig] ); const handleSaveToCurrent = () => { @@ -370,7 +354,7 @@ export default function ResourceLauncher({ }; return ( -
+
{ const value = event.target.value; setSearchInput(value); - debouncedSyncSearch( + debouncedNavigateSearch( activeViewIdRef.current, value ); @@ -397,16 +381,14 @@ export default function ResourceLauncher({ className="pl-8" />
- {!viewsLoading ? ( - ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> - ) : null} + ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + />
diff --git a/src/lib/launcherSearchParams.ts b/src/lib/launcherSearchParams.ts new file mode 100644 index 000000000..acaccd06f --- /dev/null +++ b/src/lib/launcherSearchParams.ts @@ -0,0 +1,43 @@ +import type { LauncherListQuery } from "@server/routers/launcher/types"; + +export type LauncherQueryFilters = { + query?: string; + groupBy?: LauncherListQuery["groupBy"]; + groupKey?: string; + siteIds?: number[]; + labelIds?: number[]; + sort_by?: LauncherListQuery["sort_by"]; + order?: LauncherListQuery["order"]; + pageSize?: number; +}; + +export function buildLauncherSearchParams( + filters: LauncherQueryFilters, + page: number +) { + const sp = new URLSearchParams(); + sp.set("page", String(page)); + sp.set("pageSize", String(filters.pageSize ?? 20)); + if (filters.query) { + sp.set("query", filters.query); + } + if (filters.groupBy) { + sp.set("groupBy", filters.groupBy); + } + if (filters.groupKey) { + sp.set("groupKey", filters.groupKey); + } + if (filters.siteIds?.length) { + sp.set("siteIds", filters.siteIds.join(",")); + } + if (filters.labelIds?.length) { + sp.set("labelIds", filters.labelIds.join(",")); + } + if (filters.sort_by) { + sp.set("sort_by", filters.sort_by); + } + if (filters.order) { + sp.set("order", filters.order); + } + return sp; +} diff --git a/src/lib/launcherServerData.ts b/src/lib/launcherServerData.ts new file mode 100644 index 000000000..da8fb1846 --- /dev/null +++ b/src/lib/launcherServerData.ts @@ -0,0 +1,128 @@ +import { internal } from "@app/lib/api"; +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +import { resolveLauncherStateFromUrl } from "@app/lib/launcherUrlState"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; +import type { + LauncherGroup, + LauncherResource, + LauncherViewConfig, + LauncherViewRecord, + ListLauncherGroupsResponse, + ListLauncherResourcesResponse, + ListLauncherViewsResponse +} from "@server/routers/launcher/types"; +import { AxiosResponse } from "axios"; + +export type LauncherGroupResources = { + resources: LauncherResource[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type LauncherPageData = { + views: LauncherViewRecord[]; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + savedConfig: LauncherViewConfig; + groups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + resourcesByGroupKey: Record; +}; + +const emptyResources: LauncherGroupResources = { + resources: [], + pagination: { total: 0, page: 1, pageSize: 20 } +}; + +export async function fetchLauncherPageData( + orgId: string, + searchParams: URLSearchParams, + cookieHeader: Awaited< + ReturnType + > +): Promise { + let views: LauncherViewRecord[] = []; + try { + const viewsRes = await internal.get< + AxiosResponse + >(`/org/${orgId}/launcher/views`, cookieHeader); + views = viewsRes.data.data.views; + } catch (e) {} + + const { activeViewId, config, savedConfig } = resolveLauncherStateFromUrl( + searchParams, + views, + null + ); + + const groupFilters = { + query: config.query, + groupBy: config.groupBy, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }; + + let groups: LauncherGroup[] = []; + let groupsPagination: LauncherPageData["groupsPagination"] = { + total: 0, + page: 1, + 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) {} + + const resourcesByGroupKey: Record = {}; + + await Promise.all( + groups.map(async (group) => { + try { + const sp = buildLauncherSearchParams( + { + ...groupFilters, + groupKey: group.groupKey + }, + 1 + ); + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/launcher/resources?${sp.toString()}`, + cookieHeader + ); + resourcesByGroupKey[group.groupKey] = { + resources: res.data.data.resources, + pagination: res.data.data.pagination + }; + } catch (e) { + resourcesByGroupKey[group.groupKey] = emptyResources; + } + }) + ); + + return { + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination, + resourcesByGroupKey + }; +} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 4d1cdc75c..c5d613942 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -53,6 +53,11 @@ import type { LauncherListQuery, LauncherViewConfig } from "@server/routers/launcher/types"; +import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; + +export type { LauncherQueryFilters } from "@app/lib/launcherSearchParams"; +export { buildLauncherSearchParams } from "@app/lib/launcherSearchParams"; export type ProductUpdate = { link: string | null; @@ -1174,45 +1179,6 @@ export const domainQueries = { }) }; -export type LauncherQueryFilters = { - query?: string; - groupBy?: LauncherListQuery["groupBy"]; - groupKey?: string; - siteIds?: number[]; - labelIds?: number[]; - sort_by?: LauncherListQuery["sort_by"]; - order?: LauncherListQuery["order"]; - pageSize?: number; -}; - -function launcherSearchParams(filters: LauncherQueryFilters, page: number) { - const sp = new URLSearchParams(); - sp.set("page", String(page)); - sp.set("pageSize", String(filters.pageSize ?? 20)); - if (filters.query) { - sp.set("query", filters.query); - } - if (filters.groupBy) { - sp.set("groupBy", filters.groupBy); - } - if (filters.groupKey) { - sp.set("groupKey", filters.groupKey); - } - if (filters.siteIds?.length) { - sp.set("siteIds", filters.siteIds.join(",")); - } - if (filters.labelIds?.length) { - sp.set("labelIds", filters.labelIds.join(",")); - } - if (filters.sort_by) { - sp.set("sort_by", filters.sort_by); - } - if (filters.order) { - sp.set("order", filters.order); - } - return sp; -} - export const launcherQueries = { views: (orgId: string) => queryOptions({ @@ -1228,7 +1194,7 @@ export const launcherQueries = { infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, queryFn: async ({ pageParam = 1, signal, meta }) => { - const sp = launcherSearchParams(filters, pageParam); + const sp = buildLauncherSearchParams(filters, pageParam); const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/groups?${sp.toString()}`, { signal }); @@ -1249,7 +1215,7 @@ export const launcherQueries = { infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "RESOURCES", filters] as const, queryFn: async ({ pageParam = 1, signal, meta }) => { - const sp = launcherSearchParams(filters, pageParam); + const sp = buildLauncherSearchParams(filters, pageParam); const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/launcher/resources?${sp.toString()}`, { From 9f68be2a9b588368bbdf779cd9f127cd7ea70eda Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:18:46 -0400 Subject: [PATCH 03/21] add empty state --- messages/en-US.json | 5 + .../resource-launcher/LauncherEmptyState.tsx | 118 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 36 +++++- .../resource-launcher/ResourceLauncher.tsx | 11 ++ 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/components/resource-launcher/LauncherEmptyState.tsx diff --git a/messages/en-US.json b/messages/en-US.json index d27196be2..289399e5c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3573,6 +3573,11 @@ "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoResourcesInGroup": "No resources in this group", + "resourceLauncherEmptyStateTitle": "No Resources Available", + "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", + "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.", "resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherViewNamePlaceholder": "View name", diff --git a/src/components/resource-launcher/LauncherEmptyState.tsx b/src/components/resource-launcher/LauncherEmptyState.tsx new file mode 100644 index 000000000..193ffae78 --- /dev/null +++ b/src/components/resource-launcher/LauncherEmptyState.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { cn } from "@app/lib/cn"; +import { LayoutGrid, SearchX } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type LauncherEmptyStateVariant = "empty" | "noResults"; + +type LauncherEmptyStateProps = { + variant: LauncherEmptyStateVariant; + layout: "grid" | "list"; + query?: string; + onClearFilters?: () => void; +}; + +function GhostResourceGrid() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} + +function GhostResourceList() { + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} + +export function LauncherEmptyState({ + variant, + layout, + query, + onClearFilters +}: LauncherEmptyStateProps) { + const t = useTranslations(); + const isNoResults = variant === "noResults"; + const Icon = isNoResults ? SearchX : LayoutGrid; + const trimmedQuery = query?.trim(); + + return ( +
+
+ {layout === "grid" ? ( + + ) : ( + + )} +
+
+
+ +
+
+

+ {isNoResults + ? t("resourceLauncherEmptyStateNoResultsTitle") + : t("resourceLauncherEmptyStateTitle")} +

+

+ {isNoResults + ? trimmedQuery + ? t( + "resourceLauncherEmptyStateNoResultsWithQuery", + { query: trimmedQuery } + ) + : t( + "resourceLauncherEmptyStateNoResultsDescription" + ) + : t("resourceLauncherEmptyStateDescription")} +

+
+ {isNoResults && onClearFilters ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index c90c3a7cc..7ca7e745d 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -10,6 +10,7 @@ import type { import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; import { LauncherGroupSection } from "./LauncherGroupSection"; type LauncherGroupListProps = { @@ -23,15 +24,25 @@ type LauncherGroupListProps = { pageSize: number; }; resourcesByGroupKey: Record; + onClearFilters?: () => void; }; +function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { + return ( + config.query.trim().length > 0 || + config.siteIds.length > 0 || + config.labelIds.length > 0 + ); +} + export function LauncherGroupList({ orgId, activeViewId, config, initialGroups, groupsPagination, - resourcesByGroupKey + resourcesByGroupKey, + onClearFilters }: LauncherGroupListProps) { const loadMoreRef = useRef(null); @@ -55,7 +66,7 @@ export function LauncherGroupList({ ] ); - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } = useInfiniteQuery({ ...launcherQueries.groups(orgId, groupFilters), initialData: { @@ -91,6 +102,27 @@ export function LauncherGroupList({ return () => observer.disconnect(); }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + if (groups.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + return (
{groups.map((group) => { diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index b09dea773..e5258d122 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -306,6 +306,16 @@ export default function ResourceLauncher({ [navigateToConfig] ); + const handleClearFilters = useCallback(() => { + setSearchInput(""); + navigateToConfig(activeViewIdRef.current, { + ...configRef.current, + query: "", + siteIds: [], + labelIds: [] + }); + }, [navigateToConfig]); + const handleSaveToCurrent = () => { if (isDefaultView) { return; @@ -448,6 +458,7 @@ export default function ResourceLauncher({ initialGroups={groups} groupsPagination={groupsPagination} resourcesByGroupKey={resourcesByGroupKey} + onClearFilters={handleClearFilters} /> From 376dd465b3bf8a6c545ca01ede19cc477c7644c7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 21:49:08 -0400 Subject: [PATCH 04/21] show no site category --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 114 +++++++++++++++++- server/routers/launcher/types.ts | 1 + .../LauncherGroupSection.tsx | 10 +- 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 289399e5c..be9fe0d3d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3572,6 +3572,7 @@ "resourceLauncherDeleteView": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherUnlabeled": "Unlabeled", + "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherEmptyStateTitle": "No Resources Available", "resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 95b2b16e1..47e3529fe 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -27,6 +27,7 @@ import { countDistinct, eq, inArray, + isNull, like, or, sql @@ -38,6 +39,7 @@ import { formatSiteResourceAccess } from "./formatLauncherAccess"; import { + LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, type LauncherGroup, type LauncherLabel, @@ -508,6 +510,83 @@ async function listSiteGroups( } } + let noSiteCount = 0; + + if (accessible.resourceIds.length > 0 && siteFilterIds.length === 0) { + const noSitePublicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (searchPublic) { + noSitePublicConditions.push(searchPublic); + } + + let noSitePublicQuery = db + .select({ + itemCount: countDistinct(resources.resourceId) + }) + .from(resources) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)); + + if (labelFilterIds.length > 0) { + noSitePublicQuery = noSitePublicQuery.innerJoin( + resourceLabels, + eq(resourceLabels.resourceId, resources.resourceId) + ); + noSitePublicConditions.push( + inArray(resourceLabels.labelId, labelFilterIds) + ); + } + + const [noSitePublicRow] = await noSitePublicQuery.where( + and(...noSitePublicConditions, isNull(targets.targetId)) + ); + + noSiteCount += Number(noSitePublicRow?.itemCount ?? 0); + } + + if (accessible.siteResourceIds.length > 0 && siteFilterIds.length === 0) { + const noSiteSiteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (searchSite) { + noSiteSiteConditions.push(searchSite); + } + + let noSiteSiteQuery = db + .select({ + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .leftJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)); + + if (labelFilterIds.length > 0) { + noSiteSiteQuery = noSiteSiteQuery.innerJoin( + siteResourceLabels, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ); + noSiteSiteConditions.push( + inArray(siteResourceLabels.labelId, labelFilterIds) + ); + } + + const [noSiteSiteRow] = await noSiteSiteQuery.where( + and(...noSiteSiteConditions, isNull(sites.siteId)) + ); + + noSiteCount += Number(noSiteSiteRow?.itemCount ?? 0); + } + let groups: LauncherGroup[] = Array.from(siteCountMap.values()).map( (row) => ({ groupKey: String(row.siteId), @@ -519,6 +598,15 @@ async function listSiteGroups( }) ); + if (noSiteCount > 0 && siteFilterIds.length === 0) { + groups.push({ + groupKey: LAUNCHER_NO_SITE_GROUP_KEY, + name: "No Site", + groupType: "site", + itemCount: noSiteCount + }); + } + groups.sort((a, b) => { const cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" @@ -923,6 +1011,20 @@ async function mapSiteResources( return result; } +function filterResourcesBySite( + items: LauncherResource[], + groupKey: string +): LauncherResource[] { + if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) { + return items.filter((item) => !item.site); + } + const siteId = Number.parseInt(groupKey, 10); + if (!Number.isFinite(siteId)) { + return items; + } + return items.filter((item) => item.site?.siteId === siteId); +} + function filterResourcesByLabel( items: LauncherResource[], groupKey: string @@ -1052,10 +1154,14 @@ export async function listLauncherResourcesForUser( filteredSiteResourceIds ); - const siteIdFilter = - query.groupBy === "site" + const parsedSiteId = + query.groupBy === "site" && + query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY ? Number.parseInt(query.groupKey, 10) - : undefined; + : Number.NaN; + const siteIdFilter = Number.isFinite(parsedSiteId) + ? parsedSiteId + : undefined; const [publicItems, siteItems] = await Promise.all([ mapPublicResources( @@ -1077,6 +1183,8 @@ export async function listLauncherResourcesForUser( 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/types.ts b/server/routers/launcher/types.ts index 6774a1549..eba5e8530 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; +export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; export const launcherViewConfigSchema = z.object({ groupBy: z.enum(["site", "label"]).default("site"), diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 8c32d9074..7b80c4a0f 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -16,6 +16,10 @@ import type { LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; +import { + LAUNCHER_NO_SITE_GROUP_KEY, + LAUNCHER_UNLABELED_GROUP_KEY +} from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; @@ -140,9 +144,11 @@ export function LauncherGroupSection({ }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); const groupTitle = - group.groupKey === "unlabeled" + group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY ? t("resourceLauncherUnlabeled") - : group.name; + : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY + ? t("resourceLauncherNoSite") + : group.name; return ( Date: Tue, 30 Jun 2026 21:54:27 -0400 Subject: [PATCH 05/21] include exit node endpoint in tcp/udp resources --- .../routers/launcher/formatLauncherAccess.ts | 43 ++++++++++++++++--- .../launcher/launcherResourceAccess.ts | 8 +++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/server/routers/launcher/formatLauncherAccess.ts b/server/routers/launcher/formatLauncherAccess.ts index 31f625bb7..c12767ce1 100644 --- a/server/routers/launcher/formatLauncherAccess.ts +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -1,3 +1,5 @@ +import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; + export type SiteResourceDestinationInput = { mode: "host" | "cidr" | "http" | "ssh"; destination: string | null; @@ -40,6 +42,7 @@ export type PublicResourceAccessInput = { ssl: boolean; proxyPort: number | null; wildcard: boolean; + exitNodeEndpoint?: string | null; }; export type SiteResourceAccessInput = { @@ -59,12 +62,20 @@ export type LauncherAccessFields = { accessUrl: string | null; }; -export function formatPublicResourceAccess( - resource: PublicResourceAccessInput +function formatTcpUdpResourceAccess( + exitNodeEndpoint: string | null | undefined, + proxyPort: number | null ): LauncherAccessFields { - const browserModes = ["http", "ssh", "rdp", "vnc"]; - if (!browserModes.includes(resource.mode)) { - const port = resource.proxyPort?.toString() ?? ""; + if (proxyPort == null) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + if (!exitNodeEndpoint?.trim()) { + const port = proxyPort.toString(); return { accessDisplay: port, accessCopyValue: port, @@ -72,6 +83,28 @@ export function formatPublicResourceAccess( }; } + const parsed = parseEndpoint(exitNodeEndpoint); + const host = parsed?.ip ?? exitNodeEndpoint.trim(); + const access = formatEndpoint(host, proxyPort); + + return { + accessDisplay: access, + accessCopyValue: access, + accessUrl: null + }; +} + +export function formatPublicResourceAccess( + resource: PublicResourceAccessInput +): LauncherAccessFields { + const browserModes = ["http", "ssh", "rdp", "vnc"]; + if (!browserModes.includes(resource.mode)) { + return formatTcpUdpResourceAccess( + resource.exitNodeEndpoint, + resource.proxyPort + ); + } + if (!resource.fullDomain) { return { accessDisplay: "", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 47e3529fe..5f4a5dd29 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -1,5 +1,6 @@ import { db } from "@server/db"; import { + exitNodes, labels, launcherViews, resourceLabels, @@ -860,11 +861,13 @@ async function mapPublicResources( siteId: sites.siteId, siteName: sites.name, siteType: sites.type, - siteOnline: sites.online + siteOnline: sites.online, + exitNodeEndpoint: exitNodes.endpoint }) .from(resources) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .where( and( inArray(resources.resourceId, resourceIds), @@ -891,7 +894,8 @@ async function mapPublicResources( fullDomain: row.fullDomain, ssl: row.ssl, proxyPort: row.proxyPort, - wildcard: row.wildcard + wildcard: row.wildcard, + exitNodeEndpoint: row.exitNodeEndpoint }); result.push({ From bc759c5c9eefd6a41a3a390494611dbc905c1a8f Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 30 Jun 2026 22:11:25 -0400 Subject: [PATCH 06/21] improve mobile responsiveness --- .../LauncherGroupTrigger.tsx | 2 +- .../LauncherResourceAccess.tsx | 6 +- .../LauncherResourceCard.tsx | 3 +- .../LauncherResourceList.tsx | 22 +++---- .../resource-launcher/LauncherResourceRow.tsx | 30 ++++++---- .../resource-launcher/ResourceLauncher.tsx | 60 +++++++++---------- 6 files changed, 66 insertions(+), 57 deletions(-) diff --git a/src/components/resource-launcher/LauncherGroupTrigger.tsx b/src/components/resource-launcher/LauncherGroupTrigger.tsx index a43536fd5..cd4048d65 100644 --- a/src/components/resource-launcher/LauncherGroupTrigger.tsx +++ b/src/components/resource-launcher/LauncherGroupTrigger.tsx @@ -48,7 +48,7 @@ export function LauncherGroupTrigger({ isOpen }: LauncherGroupTriggerProps) { return ( - + {group.groupType === "site" || group.groupType === "label" ? ( ) : null} diff --git a/src/components/resource-launcher/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx index 729036589..81a867e17 100644 --- a/src/components/resource-launcher/LauncherResourceAccess.tsx +++ b/src/components/resource-launcher/LauncherResourceAccess.tsx @@ -27,18 +27,18 @@ export function LauncherResourceAccess({ if (variant === "list") { return ( -
+
{canLink ? ( {accessDisplay} ) : ( - + {accessDisplay} )} diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx index 506893c29..039877393 100644 --- a/src/components/resource-launcher/LauncherResourceCard.tsx +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -29,8 +29,7 @@ export function LauncherResourceCard({ return (
- {resources.map((resource, index) => ( - - ))} +
+
+ {resources.map((resource, index) => ( + + ))} +
); } diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index 21dd763fe..b3cbde724 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -36,9 +36,8 @@ export function LauncherResourceRow({ return (
- +
+ - - {resource.name} - + + {resource.name} + +
{hasTags ? ( -
+
{showSiteTags && resource.site ? (
-
-
-
- - { - const value = event.target.value; - setSearchInput(value); - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> -
-
+
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedNavigateSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> +
From 561f75b6b1df7ad6ac0d486c7ff031bbaac90d30 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 10:03:06 -0400 Subject: [PATCH 07/21] improve responsiveness --- .../LauncherGroupSection.tsx | 1 - .../LauncherGroupTrigger.tsx | 2 +- .../LauncherResourceList.tsx | 5 +- .../resource-launcher/LauncherResourceRow.tsx | 52 +++++-------------- .../LauncherSettingsMenu.tsx | 15 ------ .../resource-launcher/ResourceLauncher.tsx | 4 +- src/lib/launcherUrlState.ts | 14 ----- 7 files changed, 18 insertions(+), 75 deletions(-) diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 7b80c4a0f..2d1b483dd 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -180,7 +180,6 @@ export function LauncherGroupSection({ )}
+ {group.groupType === "site" || group.groupType === "label" ? ( ) : null} diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index 781d361c8..d95b58adf 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,13 +6,11 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; - showSiteTags: boolean; }; export function LauncherResourceList({ resources, - showLabels, - showSiteTags + showLabels }: LauncherResourceListProps) { return (
@@ -22,7 +20,6 @@ export function LauncherResourceList({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} - showSiteTags={showSiteTags} isLast={index === resources.length - 1} /> ))} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index b3cbde724..d94823b27 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -1,6 +1,5 @@ "use client"; -import { LabelBadge } from "@app/components/label-badge"; import { cn } from "@app/lib/cn"; import type { LauncherResource } from "@server/routers/launcher/types"; import { LauncherLabelsRow } from "./LauncherLabelsRow"; @@ -14,19 +13,15 @@ import { type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; - showSiteTags: boolean; isLast?: boolean; }; export function LauncherResourceRow({ resource, showLabels, - showSiteTags, isLast = false }: LauncherResourceRowProps) { - const hasTags = - (showSiteTags && resource.site) || - (showLabels && resource.labels.length > 0); + const hasTags = showLabels && resource.labels.length > 0; const { handleAction, isClickable } = useLauncherResourceAction({ accessUrl: resource.accessUrl, accessCopyValue: resource.accessCopyValue @@ -45,24 +40,15 @@ export function LauncherResourceRow({ role={clickProps.role} tabIndex={clickProps.tabIndex} > -
- + - - {resource.name} - -
+ + {resource.name} + - {showSiteTags && resource.site ? ( - - ) : null} - {showLabels ? ( - - ) : null} +
) : null}
diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx index 086df395d..41143cd10 100644 --- a/src/components/resource-launcher/LauncherSettingsMenu.tsx +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -115,21 +115,6 @@ export function LauncherSettingsMenu({ } />
-
- - - onConfigChange({ showSiteTags: checked }) - } - /> -
{!isDefaultView ? ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 435388ece..46a92cc03 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -372,7 +372,7 @@ export default function ResourceLauncher({
-
+
-
+
0) { params.set("siteIds", config.siteIds.join(",")); From 3c37e10638ebf4fa052396b3b78fd80d9b5b47a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 10:11:17 -0400 Subject: [PATCH 08/21] add mobile overflow menu --- src/components/Layout.tsx | 2 + src/components/LayoutMobileMenu.tsx | 148 ++++++++++++++---- .../resource-launcher/ResourceLauncher.tsx | 60 +++---- 3 files changed, 149 insertions(+), 61 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5657366f8..32d4d620c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -68,6 +68,8 @@ export async function Layout({ navItems={navItems} showSidebar={showSidebar} showTopBar={showTopBar} + launcherMode={launcherMode} + showViewAsAdmin={showViewAsAdmin} /> )} diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 13efdd564..b549d1f2e 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -6,7 +6,7 @@ import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; import { Button } from "@app/components/ui/button"; -import { ArrowRight, Menu, Server } from "lucide-react"; +import { Menu, Server, Settings, SquareMousePointer } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -29,6 +29,8 @@ interface LayoutMobileMenuProps { navItems: SidebarNavSection[]; showSidebar: boolean; showTopBar: boolean; + launcherMode?: boolean; + showViewAsAdmin?: boolean; } export function LayoutMobileMenu({ @@ -36,19 +38,33 @@ export function LayoutMobileMenu({ orgs, navItems, showSidebar, - showTopBar + showTopBar, + launcherMode = false, + showViewAsAdmin = false }: LayoutMobileMenuProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const pathname = usePathname(); const isAdminPage = pathname?.startsWith("/admin"); const { user } = useUserContext(); const t = useTranslations(); + const showMobileNav = showSidebar || launcherMode; + const currentOrg = orgs?.find((org) => org.orgId === orgId); + const isSettingsPage = Boolean( + orgId && pathname?.includes(`/${orgId}/settings`) + ); + const canViewResourceLauncher = Boolean( + currentOrg?.isAdmin || currentOrg?.isOwner + ); + + const mobileNavLinkClassName = cn( + "flex items-center rounded transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-3 py-1.5" + ); return (
- {showSidebar && ( + {showMobileNav && (
{t("navbarDescription")} -
-
- -
-
-
-
- {!isAdminPage && - user.serverAdmin && ( + {launcherMode ? ( + <> +
+
+ +
+
+ {showViewAsAdmin && orgId ? ( +
setIsMobileMenuOpen( false @@ -94,25 +110,95 @@ export function LayoutMobileMenu({ } > - + {t( - "serverAdmin" + "resourceLauncherViewAsAdmin" )}
- )} - - setIsMobileMenuOpen(false) - } - /> -
-
-
+
+ ) : null} + + ) : ( + <> +
+
+ +
+
+
+
+ {!isAdminPage && + isSettingsPage && + canViewResourceLauncher && + orgId && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "resourceLauncherTitle" + )} + + +
+ )} + {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "serverAdmin" + )} + + +
+ )} + + setIsMobileMenuOpen( + false + ) + } + /> +
+
+
+ + )}
diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 46a92cc03..62e22f6a0 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -371,8 +371,36 @@ export default function ResourceLauncher({ />
-
-
+
+
+
+ + { + const value = event.target.value; + setSearchInput(value); + debouncedNavigateSearch( + activeViewIdRef.current, + value + ); + }} + placeholder={t( + "resourceLauncherSearchPlaceholder" + )} + className="pl-8" + /> +
+ ({ + viewId: view.viewId, + name: view.name + }))} + onSelectView={selectView} + /> +
+
-
-
- - { - const value = event.target.value; - setSearchInput(value); - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> -
From 22dd4220feddfb2d83a866128628548354deb207 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:09:26 -0400 Subject: [PATCH 09/21] fix ordering of buttons --- src/components/resource-launcher/ResourceLauncher.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 62e22f6a0..d4098e106 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -371,8 +371,8 @@ export default function ResourceLauncher({ />
-
-
+
+
-
+
Date: Wed, 1 Jul 2026 11:26:14 -0400 Subject: [PATCH 10/21] standardize permissions in api --- messages/en-US.json | 1 + server/auth/actions.ts | 3 +- server/routers/integration.ts | 36 ---------- server/routers/launcher/createLauncherView.ts | 33 +++------- server/routers/launcher/deleteLauncherView.ts | 27 +++----- .../launcher/launcherResourceAccess.ts | 66 +------------------ server/routers/launcher/listLauncherGroups.ts | 12 +--- .../routers/launcher/listLauncherResources.ts | 12 +--- server/routers/launcher/listLauncherViews.ts | 14 +--- server/routers/launcher/updateLauncherView.ts | 39 +++++------ 10 files changed, 45 insertions(+), 198 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index be9fe0d3d..3663c8c59 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1401,6 +1401,7 @@ "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", + "actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71fb33156..093013e6d 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -178,7 +178,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + createOrgWideLauncherView = "createOrgWideLauncherView" } export async function checkUserActionPermission( diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 0bbe12163..d124263d5 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -13,7 +13,6 @@ import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; -import * as launcher from "./launcher"; import { verifyApiKey, verifyApiKeyOrgAccess, @@ -161,41 +160,6 @@ authenticated.get( resource.getUserResources ); -authenticated.get( - "/org/:orgId/launcher/groups", - verifyApiKeyOrgAccess, - launcher.listLauncherGroups -); - -authenticated.get( - "/org/:orgId/launcher/resources", - verifyApiKeyOrgAccess, - launcher.listLauncherResources -); - -authenticated.get( - "/org/:orgId/launcher/views", - verifyApiKeyOrgAccess, - launcher.listLauncherViews -); - -authenticated.post( - "/org/:orgId/launcher/views", - verifyApiKeyOrgAccess, - launcher.createLauncherView -); - -authenticated.put( - "/org/:orgId/launcher/views/:viewId", - verifyApiKeyOrgAccess, - launcher.updateLauncherView -); - -authenticated.delete( - "/org/:orgId/launcher/views/:viewId", - verifyApiKeyOrgAccess, - launcher.deleteLauncherView -); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", diff --git a/server/routers/launcher/createLauncherView.ts b/server/routers/launcher/createLauncherView.ts index fb7ff2630..593bad304 100644 --- a/server/routers/launcher/createLauncherView.ts +++ b/server/routers/launcher/createLauncherView.ts @@ -1,17 +1,12 @@ import { db, launcherViews } from "@server/db"; import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; -import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { launcherViewConfigSchema } from "./types"; const createLauncherViewBodySchema = z.strictObject({ @@ -26,14 +21,8 @@ export async function createLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -51,22 +40,16 @@ export async function createLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - if (parsed.data.orgWide) { - const canManageOrgWide = await isOrgAdminOrOwner( - orgId, - userId, - userRoleIds + const canCreateOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req ); - if (!canManageOrgWide) { + if (!canCreateOrgWide) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can create org-wide views" + "User does not have permission perform this action" ) ); } diff --git a/server/routers/launcher/deleteLauncherView.ts b/server/routers/launcher/deleteLauncherView.ts index c68c6530c..beaf1b433 100644 --- a/server/routers/launcher/deleteLauncherView.ts +++ b/server/routers/launcher/deleteLauncherView.ts @@ -5,10 +5,7 @@ import HttpCode from "@server/types/HttpCode"; import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; export async function deleteLauncherView( req: Request, @@ -16,18 +13,12 @@ export async function deleteLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); + const orgId = req.userOrgId; + const userId = req.user!.userId; const viewId = Number.parseInt( getFirstString(req.params.viewId) ?? "", 10 ); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } if (!orgId || !Number.isFinite(viewId)) { return next( @@ -38,11 +29,6 @@ export async function deleteLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - const [existing] = await db .select() .from(launcherViews) @@ -62,9 +48,12 @@ export async function deleteLauncherView( const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; - const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); - if (!isPersonalView && !(isOrgWideView && isAdmin)) { + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 5f4a5dd29..3083c0672 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -15,7 +15,6 @@ import { sites, targets, userOrgRoles, - userOrgs, userPolicies, userResources, userSiteResources @@ -33,8 +32,6 @@ import { or, sql } from "drizzle-orm"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; import { formatPublicResourceAccess, formatSiteResourceAccess @@ -58,65 +55,6 @@ export type AccessibleIds = { siteResourceIds: number[]; }; -export async function verifyLauncherOrgMembership( - orgId: string, - userId: string -): Promise<{ userRoleIds: number[] }> { - const [userOrg] = await db - .select() - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .limit(1); - - if (!userOrg) { - throw createHttpError(HttpCode.FORBIDDEN, "User not in organization"); - } - - const userRoleIds = await db - .select({ roleId: userOrgRoles.roleId }) - .from(userOrgRoles) - .where( - and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) - ) - .then((rows) => rows.map((r) => r.roleId)); - - return { userRoleIds }; -} - -export async function isOrgAdminOrOwner( - orgId: string, - userId: string, - userRoleIds: number[] -): Promise { - const [membership] = await db - .select({ isOwner: userOrgs.isOwner }) - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .limit(1); - - if (membership?.isOwner) { - return true; - } - - if (userRoleIds.length === 0) { - return false; - } - - const adminRoles = await db - .select({ roleId: roles.roleId }) - .from(roles) - .where( - and( - eq(roles.orgId, orgId), - eq(roles.isAdmin, true), - inArray(roles.roleId, userRoleIds) - ) - ) - .limit(1); - - return adminRoles.length > 0; -} - export async function resolveAccessibleIds( orgId: string, userId: string, @@ -826,9 +764,9 @@ async function listLabelGroups( export async function listLauncherGroupsForUser( orgId: string, userId: string, + userRoleIds: number[], query: LauncherListQuery ): Promise<{ groups: LauncherGroup[]; total: number }> { - const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); if (query.groupBy === "label") { @@ -1077,9 +1015,9 @@ function sortLauncherResources( export async function listLauncherResourcesForUser( orgId: string, userId: string, + userRoleIds: number[], query: LauncherListQuery & { groupKey: string } ): Promise<{ resources: LauncherResource[]; total: number }> { - const { userRoleIds } = await verifyLauncherOrgMembership(orgId, userId); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const siteFilterIds = parseIdListParam(query.siteIds); diff --git a/server/routers/launcher/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts index 69ad1a10d..a57e5864a 100644 --- a/server/routers/launcher/listLauncherGroups.ts +++ b/server/routers/launcher/listLauncherGroups.ts @@ -1,5 +1,4 @@ import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; @@ -13,14 +12,8 @@ export async function listLauncherGroups( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -41,6 +34,7 @@ export async function listLauncherGroups( const { groups, total } = await listLauncherGroupsForUser( orgId, userId, + req.userOrgRoleIds ?? [], parsed.data ); diff --git a/server/routers/launcher/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts index a4b2be993..94f18b864 100644 --- a/server/routers/launcher/listLauncherResources.ts +++ b/server/routers/launcher/listLauncherResources.ts @@ -1,5 +1,4 @@ import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; @@ -18,14 +17,8 @@ export async function listLauncherResources( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -46,6 +39,7 @@ export async function listLauncherResources( const { resources, total } = await listLauncherResourcesForUser( orgId, userId, + req.userOrgRoleIds ?? [], parsed.data ); diff --git a/server/routers/launcher/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts index 019dbd2da..e176bae46 100644 --- a/server/routers/launcher/listLauncherViews.ts +++ b/server/routers/launcher/listLauncherViews.ts @@ -1,12 +1,10 @@ import { db, launcherViews } from "@server/db"; import { response } from "@server/lib/response"; -import { getFirstString } from "@server/lib/requestParams"; 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"; -import { verifyLauncherOrgMembership } from "./launcherResourceAccess"; function mapViewRow( row: typeof launcherViews.$inferSelect @@ -29,14 +27,8 @@ export async function listLauncherViews( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } + const orgId = req.userOrgId; + const userId = req.user!.userId; if (!orgId) { return next( @@ -44,8 +36,6 @@ export async function listLauncherViews( ); } - await verifyLauncherOrgMembership(orgId, userId); - const rows = await db .select() .from(launcherViews) diff --git a/server/routers/launcher/updateLauncherView.ts b/server/routers/launcher/updateLauncherView.ts index 2db865952..9450da153 100644 --- a/server/routers/launcher/updateLauncherView.ts +++ b/server/routers/launcher/updateLauncherView.ts @@ -8,10 +8,7 @@ import createHttpError from "http-errors"; import moment from "moment"; import { fromZodError } from "zod-validation-error"; import { z } from "zod"; -import { - isOrgAdminOrOwner, - verifyLauncherOrgMembership -} from "./launcherResourceAccess"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { launcherViewConfigSchema } from "./types"; const updateLauncherViewBodySchema = z.strictObject({ @@ -26,18 +23,12 @@ export async function updateLauncherView( next: NextFunction ): Promise { try { - const orgId = getFirstString(req.params.orgId); + const orgId = req.userOrgId; + const userId = req.user!.userId; const viewId = Number.parseInt( getFirstString(req.params.viewId) ?? "", 10 ); - const userId = req.user?.userId; - - if (!userId) { - return next( - createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") - ); - } if (!orgId || !Number.isFinite(viewId)) { return next( @@ -58,11 +49,6 @@ export async function updateLauncherView( ); } - const { userRoleIds } = await verifyLauncherOrgMembership( - orgId, - userId - ); - const [existing] = await db .select() .from(launcherViews) @@ -82,9 +68,12 @@ export async function updateLauncherView( const isPersonalView = existing.userId === userId; const isOrgWideView = existing.userId == null; - const isAdmin = await isOrgAdminOrOwner(orgId, userId, userRoleIds); + const canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); - if (!isPersonalView && !(isOrgWideView && isAdmin)) { + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -93,20 +82,24 @@ export async function updateLauncherView( ); } - if (parsed.data.orgWide === true && !isAdmin) { + if (parsed.data.orgWide === true && !canManageOrgWide) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can make views org-wide" + "User does not have permission perform this action" ) ); } - if (parsed.data.orgWide === false && isOrgWideView && !isAdmin) { + if ( + parsed.data.orgWide === false && + isOrgWideView && + !canManageOrgWide + ) { return next( createHttpError( HttpCode.FORBIDDEN, - "Only administrators can change org-wide views" + "User does not have permission perform this action" ) ); } From 97cdb2eb5acca95860218405f608e80d3b41ad40 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:46:20 -0400 Subject: [PATCH 11/21] use proper label filter selector --- src/components/LabelColumnFilterButton.tsx | 79 +++------------ src/components/LabelsFilterSelector.tsx | 98 +++++++++++++++++++ .../LauncherFilterPopover.tsx | 63 +++++++++--- 3 files changed, 164 insertions(+), 76 deletions(-) create mode 100644 src/components/LabelsFilterSelector.tsx diff --git a/src/components/LabelColumnFilterButton.tsx b/src/components/LabelColumnFilterButton.tsx index ed6a1f744..9cab6c72a 100644 --- a/src/components/LabelColumnFilterButton.tsx +++ b/src/components/LabelColumnFilterButton.tsx @@ -1,14 +1,6 @@ "use client"; import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; import { Popover, PopoverContent, @@ -16,16 +8,15 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; -import { CheckIcon, Funnel } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { useMemo, useState } from "react"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; -import { useDebounce } from "use-debounce"; +import { Funnel } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; import { LabelBadge } from "./label-badge"; import { LabelOverflowBadge } from "./label-overflow-badge"; +import { LabelsFilterSelector } from "./LabelsFilterSelector"; import { LABEL_COLORS } from "./labels-selector"; -import { Checkbox } from "./ui/checkbox"; function areSelectionsEqual(a: string[], b: string[]) { if (a.length !== b.length) { @@ -54,13 +45,9 @@ export function LabelColumnFilterButton({ const [draftValues, setDraftValues] = useState(selectedValues); const t = useTranslations(); - const [labelSearchQuery, setlabelsSearchQuery] = useState(""); - const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( orgQueries.labels({ orgId, - query: debouncedQuery, perPage: 500 }) ); @@ -152,53 +139,17 @@ export function LabelColumnFilterButton({ className={dataTableFilterPopoverContentClassName} align="start" > - - - - {t("labelsNotFound")} - - {draftValues.length > 0 && ( - { - setDraftValues([]); - }} - className="text-muted-foreground" - > - {t("accessFilterClear")} - - )} - {labels.map((label) => ( - { - toggle(label.name); - }} - className="flex items-center gap-2" - > - -
- {label.name} - - ))} - - - + draftSet.has(label.name)} + onToggle={(label) => { + toggle(label.name); + }} + showClear={draftValues.length > 0} + onClear={() => { + setDraftValues([]); + }} + />
diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx new file mode 100644 index 000000000..610bbd224 --- /dev/null +++ b/src/components/LabelsFilterSelector.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useDebounce } from "use-debounce"; +import { Checkbox } from "./ui/checkbox"; + +export type LabelFilterOption = { + labelId: number; + name: string; + color: string; +}; + +type LabelsFilterSelectorProps = { + orgId: string; + isSelected: (label: LabelFilterOption) => boolean; + onToggle: (label: LabelFilterOption) => void; + onClear?: () => void; + showClear?: boolean; +}; + +export function LabelsFilterSelector({ + orgId, + isSelected, + onToggle, + onClear, + showClear = false +}: LabelsFilterSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }) + ); + + return ( + + + + {t("labelsNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {labels.map((label) => ( + { + onToggle(label); + }} + className="flex items-center gap-2" + > + +
+ {label.name} + + ))} + + + + ); +} diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index 36d6a3baa..bcf072086 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -6,9 +6,10 @@ import { } from "@app/components/multi-site-selector"; import { formatLabelsSelectorLabel, - LabelsSelector, + LABEL_COLORS, type SelectedLabel } from "@app/components/labels-selector"; +import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; import { Button } from "@app/components/ui/button"; import { Popover, @@ -16,9 +17,11 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import type { Selectedsite } from "@app/components/site-selector"; type LauncherFilterPopoverProps = { @@ -40,6 +43,34 @@ export function LauncherFilterPopover({ const [sitesOpen, setSitesOpen] = useState(false); const [labelsOpen, setLabelsOpen] = useState(false); + const { data: labels = [] } = useQuery( + orgQueries.labels({ + orgId, + perPage: 500 + }) + ); + + const selectedLabelIds = useMemo( + () => new Set(selectedLabels.map((label) => label.labelId)), + [selectedLabels] + ); + + const resolvedSelectedLabels: SelectedLabel[] = useMemo( + () => + selectedLabels.map((selected) => { + const found = labels.find( + (label) => label.labelId === selected.labelId + ); + return ( + found ?? { + ...selected, + color: selected.color || LABEL_COLORS.gray + } + ); + }), + [labels, selectedLabels] + ); + return ( @@ -101,7 +132,7 @@ export function LauncherFilterPopover({ > {formatLabelsSelectorLabel( - selectedLabels, + resolvedSelectedLabels, t )} @@ -112,16 +143,15 @@ export function LauncherFilterPopover({ className="w-[var(--radix-popover-trigger-width)] p-0" align="start" > - { - if (action === "attach") { - onLabelsChange([ - ...selectedLabels, - label - ]); - } else { + isSelected={(label) => + selectedLabelIds.has(label.labelId) + } + onToggle={(label) => { + if ( + selectedLabelIds.has(label.labelId) + ) { onLabelsChange( selectedLabels.filter( (item) => @@ -129,8 +159,17 @@ export function LauncherFilterPopover({ label.labelId ) ); + } else { + onLabelsChange([ + ...selectedLabels, + label + ]); } }} + showClear={selectedLabels.length > 0} + onClear={() => { + onLabelsChange([]); + }} /> From 75f481bc3de0a36a7d7efe11436710783535e581 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 11:53:11 -0400 Subject: [PATCH 12/21] improve search bar debounce behavior --- .../resource-launcher/ResourceLauncher.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index d4098e106..979d67b54 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -85,22 +85,17 @@ export default function ResourceLauncher({ const { navigate, isNavigating, searchParams } = useNavigationContext(); const hasRestoredLastView = useRef(false); - const [searchInput, setSearchInput] = useState(config.query); + const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); const configRef = useRef(config); configRef.current = config; - const searchInputRef = useRef(searchInput); - searchInputRef.current = searchInput; + const searchInputRef = useRef(config.query); const activeViewIdRef = useRef(activeViewId); activeViewIdRef.current = activeViewId; - useEffect(() => { - setSearchInput(config.query); - }, [config.query]); - useEffect(() => { if (hasRestoredLastView.current) { return; @@ -307,7 +302,8 @@ export default function ResourceLauncher({ ); const handleClearFilters = useCallback(() => { - setSearchInput(""); + searchInputRef.current = ""; + setSearchInputResetKey((key) => key + 1); navigateToConfig(activeViewIdRef.current, { ...configRef.current, query: "", @@ -376,10 +372,11 @@ export default function ResourceLauncher({
{ - const value = event.target.value; - setSearchInput(value); + const value = event.currentTarget.value; + searchInputRef.current = value; debouncedNavigateSearch( activeViewIdRef.current, value @@ -389,6 +386,7 @@ export default function ResourceLauncher({ "resourceLauncherSearchPlaceholder" )} className="pl-8" + type="search" />
Date: Wed, 1 Jul 2026 12:17:36 -0400 Subject: [PATCH 13/21] show sites and labels for non admins --- server/routers/external.ts | 12 + server/routers/launcher/index.ts | 2 + .../launcher/launcherResourceAccess.ts | 260 ++++++++++++++++++ server/routers/launcher/listLauncherLabels.ts | 67 +++++ server/routers/launcher/listLauncherSites.ts | 67 +++++ server/routers/launcher/types.ts | 34 +++ src/components/LabelsFilterSelector.tsx | 27 +- src/components/multi-site-selector.tsx | 27 +- .../LauncherFilterPopover.tsx | 35 ++- src/lib/queries.ts | 68 +++++ 10 files changed, 583 insertions(+), 16 deletions(-) create mode 100644 server/routers/launcher/listLauncherLabels.ts create mode 100644 server/routers/launcher/listLauncherSites.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index b4ca30cff..2ff5d6c4f 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -468,6 +468,18 @@ authenticated.get( launcher.listLauncherResources ); +authenticated.get( + "/org/:orgId/launcher/sites", + verifyOrgAccess, + launcher.listLauncherSites +); + +authenticated.get( + "/org/:orgId/launcher/labels", + verifyOrgAccess, + launcher.listLauncherLabels +); + authenticated.get( "/org/:orgId/launcher/views", verifyOrgAccess, diff --git a/server/routers/launcher/index.ts b/server/routers/launcher/index.ts index f23b266ea..939bf74bc 100644 --- a/server/routers/launcher/index.ts +++ b/server/routers/launcher/index.ts @@ -1,6 +1,8 @@ export * from "./types"; export { listLauncherGroups } from "./listLauncherGroups"; export { listLauncherResources } from "./listLauncherResources"; +export { listLauncherSites } from "./listLauncherSites"; +export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherViews } from "./listLauncherViews"; export { createLauncherView } from "./createLauncherView"; export { updateLauncherView } from "./updateLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 3083c0672..85ca24a23 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -39,10 +39,12 @@ import { import { LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY, + type LauncherFilterListQuery, type LauncherGroup, type LauncherLabel, type LauncherListQuery, type LauncherResource, + type LauncherSiteInfo, parseIdListParam } from "./types"; @@ -1138,3 +1140,261 @@ export async function listLauncherResourcesForUser( total }; } + +function buildSiteNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return or( + like(sql`LOWER(${sites.name})`, pattern), + like(sql`LOWER(${sites.niceId})`, pattern) + ); +} + +function buildLabelNameSearchCondition(query: string) { + if (!query.trim()) { + return undefined; + } + const pattern = searchPattern(query.toLowerCase()); + return like(sql`LOWER(${labels.name})`, pattern); +} + +async function collectAccessibleSites( + orgId: string, + accessible: AccessibleIds, + siteNameSearch?: ReturnType +): Promise> { + const siteCountMap = new Map(); + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ]; + if (siteNameSearch) { + publicConditions.push(siteNameSearch); + } + + const publicRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(resources.resourceId) + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where(and(...publicConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of publicRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ]; + if (siteNameSearch) { + siteConditions.push(siteNameSearch); + } + + const siteRows = await db + .select({ + siteId: sites.siteId, + name: sites.name, + type: sites.type, + online: sites.online, + itemCount: countDistinct(siteResources.siteResourceId) + }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where(and(...siteConditions)) + .groupBy(sites.siteId, sites.name, sites.type, sites.online); + + for (const row of siteRows) { + const existing = siteCountMap.get(row.siteId); + if (existing) { + existing.itemCount += Number(row.itemCount); + } else { + siteCountMap.set(row.siteId, { + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online, + itemCount: Number(row.itemCount) + }); + } + } + } + + return siteCountMap; +} + +async function collectAccessibleLabels( + orgId: string, + accessible: AccessibleIds, + labelNameSearch?: ReturnType +): Promise> { + const labelMap = new Map(); + + if (!(await labelsEnabled(orgId))) { + return labelMap; + } + + if (accessible.resourceIds.length > 0) { + const publicConditions = [ + inArray(resources.resourceId, accessible.resourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + publicConditions.push(labelNameSearch); + } + + const labeledPublic = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(resourceLabels) + .innerJoin(labels, eq(resourceLabels.labelId, labels.labelId)) + .innerJoin( + resources, + eq(resourceLabels.resourceId, resources.resourceId) + ) + .where(and(...publicConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledPublic) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + if (accessible.siteResourceIds.length > 0) { + const siteConditions = [ + inArray(siteResources.siteResourceId, accessible.siteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true), + eq(labels.orgId, orgId) + ]; + if (labelNameSearch) { + siteConditions.push(labelNameSearch); + } + + const labeledSite = await db + .select({ + labelId: labels.labelId, + name: labels.name, + color: labels.color + }) + .from(siteResourceLabels) + .innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId)) + .innerJoin( + siteResources, + eq( + siteResourceLabels.siteResourceId, + siteResources.siteResourceId + ) + ) + .where(and(...siteConditions)) + .groupBy(labels.labelId, labels.name, labels.color); + + for (const row of labeledSite) { + labelMap.set(row.labelId, { + labelId: row.labelId, + name: row.name, + color: row.color + }); + } + } + + return labelMap; +} + +export async function listAccessibleLauncherSitesForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ sites: LauncherSiteInfo[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const siteNameSearch = buildSiteNameSearchCondition(query.query); + const siteCountMap = await collectAccessibleSites( + orgId, + accessible, + siteNameSearch + ); + + const sites: LauncherSiteInfo[] = Array.from(siteCountMap.values()) + .map((row) => ({ + siteId: row.siteId, + name: row.name, + type: row.type, + online: row.online + })) + .sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = sites.length; + const offset = (query.page - 1) * query.pageSize; + return { + sites: sites.slice(offset, offset + query.pageSize), + total + }; +} + +export async function listAccessibleLauncherLabelsForUser( + orgId: string, + userId: string, + userRoleIds: number[], + query: LauncherFilterListQuery +): Promise<{ labels: LauncherLabel[]; total: number }> { + const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); + const labelNameSearch = buildLabelNameSearchCondition(query.query); + const labelMap = await collectAccessibleLabels( + orgId, + accessible, + labelNameSearch + ); + + const labelsList = Array.from(labelMap.values()).sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + + const total = labelsList.length; + const offset = (query.page - 1) * query.pageSize; + return { + labels: labelsList.slice(offset, offset + query.pageSize), + total + }; +} diff --git a/server/routers/launcher/listLauncherLabels.ts b/server/routers/launcher/listLauncherLabels.ts new file mode 100644 index 000000000..4bb25c965 --- /dev/null +++ b/server/routers/launcher/listLauncherLabels.ts @@ -0,0 +1,67 @@ +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 { listAccessibleLauncherLabelsForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherLabels( + 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 = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { labels, total } = await listAccessibleLauncherLabelsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + labels, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher labels retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher labels:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Internal server error" + ) + ); + } +} diff --git a/server/routers/launcher/listLauncherSites.ts b/server/routers/launcher/listLauncherSites.ts new file mode 100644 index 000000000..8755f6d5a --- /dev/null +++ b/server/routers/launcher/listLauncherSites.ts @@ -0,0 +1,67 @@ +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 { listAccessibleLauncherSitesForUser } from "./launcherResourceAccess"; +import { launcherFilterListQuerySchema } from "./types"; + +export async function listLauncherSites( + 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 = launcherFilterListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { sites, total } = await listAccessibleLauncherSitesForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + parsed.data + ); + + return response(res, { + data: { + sites, + pagination: { + total, + page: parsed.data.page, + pageSize: parsed.data.pageSize + } + }, + success: true, + error: false, + message: "Launcher sites retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } + console.error("Error listing launcher sites:", 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 eba5e8530..9d08271ae 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -93,6 +93,40 @@ export type ListLauncherViewsResponse = { views: LauncherViewRecord[]; }; +export const launcherFilterListQuerySchema = z.strictObject({ + pageSize: z.coerce + .number() + .int() + .positive() + .optional() + .catch(500) + .default(500), + page: z.coerce.number().int().min(1).optional().catch(1).default(1), + query: z.string().optional().default("") +}); + +export type LauncherFilterListQuery = z.infer< + typeof launcherFilterListQuerySchema +>; + +export type ListLauncherSitesResponse = { + sites: LauncherSiteInfo[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + +export type ListLauncherLabelsResponse = { + labels: LauncherLabel[]; + pagination: { + total: number; + page: number; + pageSize: number; + }; +}; + export const launcherListQuerySchema = z.strictObject({ pageSize: z.coerce .number() diff --git a/src/components/LabelsFilterSelector.tsx b/src/components/LabelsFilterSelector.tsx index 610bbd224..3c9410e43 100644 --- a/src/components/LabelsFilterSelector.tsx +++ b/src/components/LabelsFilterSelector.tsx @@ -8,7 +8,7 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -27,6 +27,7 @@ type LabelsFilterSelectorProps = { onToggle: (label: LabelFilterOption) => void; onClear?: () => void; showClear?: boolean; + scope?: "org" | "launcher"; }; export function LabelsFilterSelector({ @@ -34,19 +35,33 @@ export function LabelsFilterSelector({ isSelected, onToggle, onClear, - showClear = false + showClear = false, + scope = "org" }: LabelsFilterSelectorProps) { const t = useTranslations(); const [labelSearchQuery, setlabelsSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(labelSearchQuery, 150); - const { data: labels = [] } = useQuery( - orgQueries.labels({ + const orgLabelsQuery = useQuery({ + ...orgQueries.labels({ orgId, query: debouncedQuery, perPage: 500 - }) - ); + }), + enabled: scope === "org" + }); + const launcherLabelsQuery = useQuery({ + ...launcherQueries.labels({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const labels = + scope === "launcher" + ? (launcherLabelsQuery.data ?? []) + : (orgLabelsQuery.data ?? []); return ( diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index acb8b7dd9..fe81dc697 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -1,4 +1,4 @@ -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries, orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { @@ -19,6 +19,7 @@ export type MultiSitesSelectorProps = { selectedSites: Selectedsite[]; onSelectionChange: (sites: Selectedsite[]) => void; filterTypes?: string[]; + scope?: "org" | "launcher"; }; export function formatMultiSitesSelectorLabel( @@ -40,19 +41,33 @@ export function MultiSitesSelector({ orgId, selectedSites, onSelectionChange, - filterTypes + filterTypes, + scope = "org" }: MultiSitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); const [debouncedQuery] = useDebounce(siteSearchQuery, 150); - const { data: sites = [] } = useQuery( - orgQueries.sites({ + const orgSitesQuery = useQuery({ + ...orgQueries.sites({ orgId, query: debouncedQuery, perPage: 10 - }) - ); + }), + enabled: scope === "org" + }); + const launcherSitesQuery = useQuery({ + ...launcherQueries.sites({ + orgId, + query: debouncedQuery, + perPage: 500 + }), + enabled: scope === "launcher" + }); + const sites = + scope === "launcher" + ? (launcherSitesQuery.data ?? []) + : (orgSitesQuery.data ?? []); const sitesShown = useMemo(() => { const base = filterTypes diff --git a/src/components/resource-launcher/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx index bcf072086..5a0e425f9 100644 --- a/src/components/resource-launcher/LauncherFilterPopover.tsx +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -17,7 +17,7 @@ import { PopoverTrigger } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; -import { orgQueries } from "@app/lib/queries"; +import { launcherQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { ChevronsUpDown, Funnel } from "lucide-react"; @@ -44,12 +44,37 @@ export function LauncherFilterPopover({ const [labelsOpen, setLabelsOpen] = useState(false); const { data: labels = [] } = useQuery( - orgQueries.labels({ + launcherQueries.labels({ orgId, perPage: 500 }) ); + const { data: sites = [] } = useQuery( + launcherQueries.sites({ + orgId, + perPage: 500 + }) + ); + + const resolvedSelectedSites: Selectedsite[] = useMemo( + () => + selectedSites.map((selected) => { + const found = sites.find( + (site) => site.siteId === selected.siteId + ); + return found + ? { + siteId: found.siteId, + name: found.name, + type: found.type, + online: found.online + } + : selected; + }), + [sites, selectedSites] + ); + const selectedLabelIds = useMemo( () => new Set(selectedLabels.map((label) => label.labelId)), [selectedLabels] @@ -98,7 +123,7 @@ export function LauncherFilterPopover({ > {formatMultiSitesSelectorLabel( - selectedSites, + resolvedSelectedSites, t )} @@ -111,8 +136,9 @@ export function LauncherFilterPopover({ > @@ -145,6 +171,7 @@ export function LauncherFilterPopover({ > selectedLabelIds.has(label.labelId) } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index c5d613942..14fa1d3da 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -48,7 +48,9 @@ import type { ListResourcePoliciesResponse } from "@server/routers/resource/type import type { GetResourcePolicyResponse } from "@server/routers/policy"; import type { ListLauncherGroupsResponse, + ListLauncherLabelsResponse, ListLauncherResourcesResponse, + ListLauncherSitesResponse, ListLauncherViewsResponse, LauncherListQuery, LauncherViewConfig @@ -1190,6 +1192,72 @@ export const launcherQueries = { return res.data.data.views; } }), + sites: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "SITES", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/sites?${sp.toString()}`, { signal }); + return res.data.data.sites; + } + }), + labels: ({ + orgId, + query, + perPage = 500 + }: { + orgId: string; + query?: string; + perPage?: number; + }) => + queryOptions({ + queryKey: [ + "ORG", + orgId, + "LAUNCHER", + "LABELS", + { query, perPage } + ] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + pageSize: perPage.toString() + }); + + if (query?.trim()) { + sp.set("query", query); + } + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/launcher/labels?${sp.toString()}`, { + signal + }); + return res.data.data.labels; + } + }), groups: (orgId: string, filters: LauncherQueryFilters) => infiniteQueryOptions({ queryKey: ["ORG", orgId, "LAUNCHER", "GROUPS", filters] as const, From 5a7ca5b542f873b76c84fc1aa0516e9d93722c54 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 12:38:45 -0400 Subject: [PATCH 14/21] add refresh button --- messages/en-US.json | 1 + .../LauncherRefreshButton.tsx | 31 +++++++++++++++ .../resource-launcher/LauncherViewTabs.tsx | 15 +++++++- .../resource-launcher/ResourceLauncher.tsx | 38 ++++++++++++++++++- 4 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/components/resource-launcher/LauncherRefreshButton.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 3663c8c59..2c938639a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3551,6 +3551,7 @@ "resourceLauncherDefaultView": "Default", "resourceLauncherSaveView": "Save View", "resourceLauncherSaveToCurrentView": "Save to Current View", + "resourceLauncherResetView": "Reset View", "resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveForEveryone": "Save for Everyone", diff --git a/src/components/resource-launcher/LauncherRefreshButton.tsx b/src/components/resource-launcher/LauncherRefreshButton.tsx new file mode 100644 index 000000000..854128736 --- /dev/null +++ b/src/components/resource-launcher/LauncherRefreshButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useTranslations } from "next-intl"; +import { RefreshCw } from "lucide-react"; + +type LauncherRefreshButtonProps = { + onRefresh: () => void; + isRefreshing: boolean; +}; + +export function LauncherRefreshButton({ + onRefresh, + isRefreshing +}: LauncherRefreshButtonProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/resource-launcher/LauncherViewTabs.tsx b/src/components/resource-launcher/LauncherViewTabs.tsx index 474d04e67..e23aaf0b4 100644 --- a/src/components/resource-launcher/LauncherViewTabs.tsx +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -5,6 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { useTranslations } from "next-intl"; @@ -71,6 +72,7 @@ type LauncherSaveViewMenuProps = { onSaveAsNew: () => void; onSaveForEveryone: () => void; onMakePersonal: () => void; + onResetView: () => void; }; export function LauncherSaveViewMenu({ @@ -81,7 +83,8 @@ export function LauncherSaveViewMenu({ onSaveToCurrent, onSaveAsNew, onSaveForEveryone, - onMakePersonal + onMakePersonal, + onResetView }: LauncherSaveViewMenuProps) { const t = useTranslations(); @@ -97,7 +100,15 @@ export function LauncherSaveViewMenu({ - {!isDefaultView ? ( + {hasUnsavedChanges ? ( + <> + + {t("resourceLauncherResetView")} + + + + ) : null} + {!isDefaultView && (isAdmin || !isOrgWideView) ? ( {t("resourceLauncherSaveToCurrentView")} diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 979d67b54..7b0628d07 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -39,12 +39,20 @@ import { useMutation } from "@tanstack/react-query"; import { Search } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; import type { SelectedLabel } from "@app/components/labels-selector"; import { LauncherFilterPopover } from "./LauncherFilterPopover"; import { LauncherGroupList } from "./LauncherGroupList"; +import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; @@ -83,6 +91,7 @@ export default function ResourceLauncher({ const api = createApiClient({ env }); const router = useRouter(); const { navigate, isNavigating, searchParams } = useNavigationContext(); + const [isRefreshing, startRefreshTransition] = useTransition(); const hasRestoredLastView = useRef(false); const [searchInputResetKey, setSearchInputResetKey] = useState(0); @@ -312,8 +321,28 @@ export default function ResourceLauncher({ }); }, [navigateToConfig]); + const handleResetView = useCallback(() => { + searchInputRef.current = savedConfig.query; + setSearchInputResetKey((key) => key + 1); + navigateToConfig(activeViewIdRef.current, savedConfig); + }, [navigateToConfig, savedConfig]); + + const refreshData = () => { + startRefreshTransition(async () => { + try { + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + }; + const handleSaveToCurrent = () => { - if (isDefaultView) { + if (isDefaultView || (isOrgWideView && !isAdmin)) { return; } updateViewMutation.mutate({ @@ -408,6 +437,7 @@ export default function ResourceLauncher({ onSaveAsNew={handleSaveAsNew} onSaveForEveryone={handleSaveForEveryone} onMakePersonal={handleMakePersonal} + onResetView={handleResetView} /> +
From 5a1d5cb66e82533e99337d74bdd8876d241c104e Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 14:06:33 -0400 Subject: [PATCH 15/21] redirect to settings if org admin --- src/app/page.tsx | 10 +++++++++- src/components/RedirectToOrg.tsx | 17 +++++++++++++---- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 188089bda..7f0f05b57 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -107,7 +107,15 @@ export default async function Page(props: { } if (targetOrgId) { - return ; + const targetOrg = orgs.find((org) => org.orgId === targetOrgId); + return ( + + ); } return ( diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index e647ee7a1..02ad97773 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -6,20 +6,29 @@ import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type RedirectToOrgProps = { targetOrgId: string; + isAdminOrOwner?: boolean; }; -export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { +export default function RedirectToOrg({ + targetOrgId, + isAdminOrOwner = false +}: RedirectToOrgProps) { const router = useRouter(); useEffect(() => { try { const target = - getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; + getInternalRedirectTarget(targetOrgId) ?? + (isAdminOrOwner + ? `/${targetOrgId}/settings` + : `/${targetOrgId}`); router.replace(target); } catch { - router.replace(`/${targetOrgId}`); + router.replace( + isAdminOrOwner ? `/${targetOrgId}/settings` : `/${targetOrgId}` + ); } - }, [targetOrgId, router]); + }, [targetOrgId, isAdminOrOwner, router]); return null; } From 0871a211ecaf7ef2509256e942ccb8105fbf96cb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 14:30:43 -0400 Subject: [PATCH 16/21] open side panel on click --- messages/en-US.json | 1 + .../launcher/launcherResourceAccess.ts | 4 + server/routers/launcher/types.ts | 1 + src/components/SidePanel.tsx | 164 ++++++++++++++++++ .../resource-launcher/LauncherGroupList.tsx | 6 +- .../LauncherGroupSection.tsx | 6 +- .../LauncherResourceCard.tsx | 15 +- .../LauncherResourceGrid.tsx | 5 +- .../LauncherResourceList.tsx | 5 +- .../LauncherResourcePanel.tsx | 68 ++++++++ .../resource-launcher/LauncherResourceRow.tsx | 15 +- .../resource-launcher/ResourceLauncher.tsx | 17 ++ .../useLauncherResourceAction.ts | 47 ++++- src/lib/launcherResourceAdminHref.ts | 17 ++ 14 files changed, 343 insertions(+), 28 deletions(-) create mode 100644 src/components/SidePanel.tsx create mode 100644 src/components/resource-launcher/LauncherResourcePanel.tsx create mode 100644 src/lib/launcherResourceAdminHref.ts diff --git a/messages/en-US.json b/messages/en-US.json index 2c938639a..1621bb8d4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3573,6 +3573,7 @@ "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", "resourceLauncherViewAsAdmin": "View as Admin", + "resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherNoSite": "No Site", "resourceLauncherNoResourcesInGroup": "No resources in this group", diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 85ca24a23..60365d496 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -791,6 +791,7 @@ async function mapPublicResources( const rows = await db .select({ resourceId: resources.resourceId, + niceId: resources.niceId, name: resources.name, mode: resources.mode, fullDomain: resources.fullDomain, @@ -842,6 +843,7 @@ async function mapPublicResources( launcherResourceKey: key, resourceType: "public", resourceId: row.resourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, @@ -876,6 +878,7 @@ async function mapSiteResources( const rows = await db .select({ siteResourceId: siteResources.siteResourceId, + niceId: siteResources.niceId, name: siteResources.name, mode: siteResources.mode, destination: siteResources.destination, @@ -934,6 +937,7 @@ async function mapSiteResources( resourceType: "site", resourceId: row.siteResourceId, siteResourceId: row.siteResourceId, + niceId: row.niceId, name: row.name, ...access, iconUrl: null, diff --git a/server/routers/launcher/types.ts b/server/routers/launcher/types.ts index 9d08271ae..0a252d74d 100644 --- a/server/routers/launcher/types.ts +++ b/server/routers/launcher/types.ts @@ -39,6 +39,7 @@ export type LauncherResource = { resourceType: "public" | "site"; resourceId: number; siteResourceId?: number; + niceId: string; name: string; accessDisplay: string; accessCopyValue: string; diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx new file mode 100644 index 000000000..969eebcfc --- /dev/null +++ b/src/components/SidePanel.tsx @@ -0,0 +1,164 @@ +"use client"; + +import * as React from "react"; + +import { useMediaQuery } from "@app/hooks/useMediaQuery"; +import { cn } from "@app/lib/cn"; +import { + Sheet, + SheetClose, + SheetDescription, + SheetFooter, + SheetHeader, + SheetOverlay, + SheetPortal, + SheetTitle, + SheetTrigger +} from "./ui/sheet"; +import * as SheetPrimitive from "@radix-ui/react-dialog"; + +type BaseProps = { + children: React.ReactNode; +}; + +type RootSidePanelProps = BaseProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +type SidePanelProps = { + className?: string; + asChild?: true; + children?: React.ReactNode; +}; + +const desktop = "(min-width: 768px)"; + +const SidePanel = ({ children, ...props }: RootSidePanelProps) => { + return {children}; +}; + +const SidePanelTrigger = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelContent = ({ + className, + children, + ...props +}: SidePanelProps) => { + const isDesktop = useMediaQuery(desktop); + + return ( + + + e.preventDefault()} + > + {children} + + + ); +}; + +const SidePanelDescription = ({ + className, + children, + ...props +}: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => { + return ( +
+
{children}
+
+
+ ); +}; + +const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => { + return ( + + {children} + + ); +}; + +export { + SidePanel, + SidePanelBody, + SidePanelClose, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle, + SidePanelTrigger +}; diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index 7ca7e745d..a631aff6c 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -5,6 +5,7 @@ import type { LauncherGroupResources } from "@app/lib/launcherServerData"; import { launcherQueries } from "@app/lib/queries"; import type { LauncherGroup, + LauncherResource, LauncherViewConfig } from "@server/routers/launcher/types"; import { useInfiniteQuery } from "@tanstack/react-query"; @@ -25,6 +26,7 @@ type LauncherGroupListProps = { }; resourcesByGroupKey: Record; onClearFilters?: () => void; + onResourceSelect: (resource: LauncherResource) => void; }; function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { @@ -42,7 +44,8 @@ export function LauncherGroupList({ initialGroups, groupsPagination, resourcesByGroupKey, - onClearFilters + onClearFilters, + onResourceSelect }: LauncherGroupListProps) { const loadMoreRef = useRef(null); @@ -137,6 +140,7 @@ export function LauncherGroupList({ config={config} initialResources={groupResources?.resources} initialResourcesPagination={groupResources?.pagination} + onResourceSelect={onResourceSelect} /> ); })} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 2d1b483dd..1d4881835 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -40,6 +40,7 @@ type LauncherGroupSectionProps = { pageSize: number; }; defaultOpen?: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherGroupSection({ @@ -49,7 +50,8 @@ export function LauncherGroupSection({ config, initialResources, initialResourcesPagination, - defaultOpen = true + defaultOpen = true, + onResourceSelect }: LauncherGroupSectionProps) { const t = useTranslations(); const loadMoreRef = useRef(null); @@ -175,11 +177,13 @@ export function LauncherGroupSection({ ) : ( )}
void; }; export function LauncherResourceCard({ resource, - showLabels + showLabels, + onSelect }: LauncherResourceCardProps) { const hasIcon = Boolean(resource.iconUrl); - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
void; }; export function LauncherResourceGrid({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceGridProps) { return (
@@ -19,6 +21,7 @@ export function LauncherResourceGrid({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} + onSelect={() => onResourceSelect(resource)} /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index d95b58adf..e64e2b7b0 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,11 +6,13 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; + onResourceSelect: (resource: LauncherResource) => void; }; export function LauncherResourceList({ resources, - showLabels + showLabels, + onResourceSelect }: LauncherResourceListProps) { return (
@@ -21,6 +23,7 @@ export function LauncherResourceList({ resource={resource} showLabels={showLabels} isLast={index === resources.length - 1} + onSelect={() => onResourceSelect(resource)} /> ))}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx new file mode 100644 index 000000000..1bb61bd9e --- /dev/null +++ b/src/components/resource-launcher/LauncherResourcePanel.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { + SidePanel, + SidePanelBody, + SidePanelContent, + SidePanelDescription, + SidePanelFooter, + SidePanelHeader, + SidePanelTitle +} from "@app/components/SidePanel"; +import { Button } from "@app/components/ui/button"; +import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref"; +import type { LauncherResource } from "@server/routers/launcher/types"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; + +type LauncherResourcePanelProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + resource: LauncherResource | null; + orgId: string; + isAdmin: boolean; +}; + +export function LauncherResourcePanel({ + open, + onOpenChange, + resource, + orgId, + isAdmin +}: LauncherResourcePanelProps) { + const t = useTranslations(); + + return ( + + + + {resource?.name ?? ""} + + {t("resourceLauncherResourceDetailsDescription")} + + + + + + {isAdmin && resource ? ( + + ) : null} + + + + ); +} diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index d94823b27..34558de51 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -5,28 +5,23 @@ import type { LauncherResource } from "@server/routers/launcher/types"; import { LauncherLabelsRow } from "./LauncherLabelsRow"; import { LauncherResourceAccess } from "./LauncherResourceAccess"; import { LauncherResourceIcon } from "./LauncherResourceIcon"; -import { - getLauncherResourceClickProps, - useLauncherResourceAction -} from "./useLauncherResourceAction"; +import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; isLast?: boolean; + onSelect: () => void; }; export function LauncherResourceRow({ resource, showLabels, - isLast = false + isLast = false, + onSelect }: LauncherResourceRowProps) { const hasTags = showLabels && resource.labels.length > 0; - const { handleAction, isClickable } = useLauncherResourceAction({ - accessUrl: resource.accessUrl, - accessCopyValue: resource.accessCopyValue - }); - const clickProps = getLauncherResourceClickProps(handleAction, isClickable); + const clickProps = getLauncherResourceSelectProps(onSelect); return (
(null); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); @@ -491,6 +495,19 @@ export default function ResourceLauncher({ groupsPagination={groupsPagination} resourcesByGroupKey={resourcesByGroupKey} onClearFilters={handleClearFilters} + onResourceSelect={setSelectedResource} + /> + + { + if (!open) { + setSelectedResource(null); + } + }} + resource={selectedResource} + orgId={orgId} + isAdmin={isAdmin} /> diff --git a/src/components/resource-launcher/useLauncherResourceAction.ts b/src/components/resource-launcher/useLauncherResourceAction.ts index 427ee64bf..4c7d081dd 100644 --- a/src/components/resource-launcher/useLauncherResourceAction.ts +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -44,28 +44,67 @@ export function useLauncherResourceAction({ } export function isLauncherResourceInteractiveTarget( - target: EventTarget | null + target: EventTarget | null, + container?: EventTarget | null ): boolean { if (!(target instanceof Element)) { return false; } - return Boolean( - target.closest("a, button, [role='button'], input, textarea, select") + const interactive = target.closest( + "a, button, [role='button'], input, textarea, select" ); + + if (!interactive) { + return false; + } + + if (container instanceof Element && interactive === container) { + return false; + } + + return true; } function handleLauncherResourceClick( event: MouseEvent, handleAction: () => void ) { - if (isLauncherResourceInteractiveTarget(event.target)) { + if ( + isLauncherResourceInteractiveTarget(event.target, event.currentTarget) + ) { return; } handleAction(); } +export function getLauncherResourceSelectProps(onSelect: () => void) { + return { + onClick: (event: MouseEvent) => { + if ( + isLauncherResourceInteractiveTarget( + event.target, + event.currentTarget + ) + ) { + return; + } + + onSelect(); + }, + className: "cursor-pointer", + role: "button" as const, + tabIndex: 0, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(); + } + } + }; +} + export function getLauncherResourceClickProps( handleAction: () => void, isClickable: boolean diff --git a/src/lib/launcherResourceAdminHref.ts b/src/lib/launcherResourceAdminHref.ts new file mode 100644 index 000000000..db7da151e --- /dev/null +++ b/src/lib/launcherResourceAdminHref.ts @@ -0,0 +1,17 @@ +import type { LauncherResource } from "@server/routers/launcher/types"; + +export function getLauncherResourceAdminHref( + orgId: string, + resource: LauncherResource +): string { + if (resource.resourceType === "public") { + return `/${orgId}/settings/resources/public/${resource.niceId}/general`; + } + + const qs = new URLSearchParams({ query: resource.niceId }); + if (resource.site?.siteId != null) { + qs.set("siteId", String(resource.site.siteId)); + } + + return `/${orgId}/settings/resources/private?${qs.toString()}`; +} From db0a7cc1cea91e434f678b1f43014f0dc3bd0c93 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:14:29 -0400 Subject: [PATCH 17/21] fix toolbar responsiveness and disable side panel --- server/setup/scriptsSqlite/1.13.0.ts | 28 +++ .../resource-launcher/LauncherGroupList.tsx | 2 +- .../LauncherGroupSection.tsx | 2 +- .../LauncherResourceCard.tsx | 16 +- .../LauncherResourceGrid.tsx | 8 +- .../LauncherResourceList.tsx | 8 +- .../resource-launcher/LauncherResourceRow.tsx | 16 +- .../resource-launcher/LauncherViewTabs.tsx | 2 +- .../resource-launcher/ResourceLauncher.tsx | 207 +++++++++--------- 9 files changed, 167 insertions(+), 122 deletions(-) diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index c4b49495f..a67b0fa14 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -271,6 +271,7 @@ export default async function migration() { `ALTER TABLE 'sites' DROP COLUMN 'remoteSubnets';` ).run(); +<<<<<<< Updated upstream // get all of the siteResources and set the aliasAddress to 100.96.128.x starting at .8 const siteResourcesForAlias = db .prepare( @@ -278,6 +279,15 @@ export default async function migration() { ) .all() as { siteResourceId: number; +======= + // Associate clients with site resources based on their previous site access + // Get all client-site associations from the renamed clientSitesAssociationsCache table + const clientSiteAssociations = db + .prepare(`SELECT clientId, siteId FROM 'clientSitesAssociationsCache'`) + .all() as { + clientId: number; + siteId: number; +>>>>>>> Stashed changes }[]; const updateAliasAddress = db.prepare( @@ -335,6 +345,7 @@ export default async function migration() { `INSERT INTO 'clientSiteResources' ('clientId', 'siteResourceId') VALUES (?, ?)` ); +<<<<<<< Updated upstream // create a clientSiteResourcesAssociationsCache entry for each existing association as well const insertClientSiteResourceCache = db.prepare( `INSERT INTO 'clientSiteResourcesAssociationsCache' ('clientId', 'siteResourceId') VALUES (?, ?)` @@ -345,6 +356,13 @@ export default async function migration() { const siteResources = getSiteResources.all( association.siteId ) as { +======= + // For each client-site association, find all site resources for that site + for (const association of clientSiteAssociations) { + const siteResources = db + .prepare(`SELECT siteResourceId FROM 'siteResources' WHERE siteId = ?`) + .all(association.siteId) as { +>>>>>>> Stashed changes siteResourceId: number; }[]; @@ -352,15 +370,25 @@ export default async function migration() { for (const siteResource of siteResources) { insertClientSiteResource.run( association.clientId, +<<<<<<< Updated upstream siteResource.siteResourceId ); insertClientSiteResourceCache.run( association.clientId, +======= +>>>>>>> Stashed changes siteResource.siteResourceId ); } } + // Get all clients for niceId population (used later) + const clients = db + .prepare(`SELECT clientId FROM 'clients'`) + .all() as { + clientId: number; + }[]; + // Associate existing site resources with their org's admin role const siteResourcesWithOrg = db .prepare(`SELECT siteResourceId, orgId FROM 'siteResources'`) diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx index a631aff6c..bb3418f8d 100644 --- a/src/components/resource-launcher/LauncherGroupList.tsx +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -26,7 +26,7 @@ type LauncherGroupListProps = { }; resourcesByGroupKey: Record; onClearFilters?: () => void; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; function hasActiveLauncherFilters(config: LauncherViewConfig): boolean { diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx index 1d4881835..49b648032 100644 --- a/src/components/resource-launcher/LauncherGroupSection.tsx +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -40,7 +40,7 @@ type LauncherGroupSectionProps = { pageSize: number; }; defaultOpen?: boolean; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherGroupSection({ diff --git a/src/components/resource-launcher/LauncherResourceCard.tsx b/src/components/resource-launcher/LauncherResourceCard.tsx index 71e9cbe8b..ac891e84b 100644 --- a/src/components/resource-launcher/LauncherResourceCard.tsx +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -10,7 +10,7 @@ import { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; type LauncherResourceCardProps = { resource: LauncherResource; showLabels: boolean; - onSelect: () => void; + onSelect?: () => void; }; export function LauncherResourceCard({ @@ -19,18 +19,20 @@ export function LauncherResourceCard({ onSelect }: LauncherResourceCardProps) { const hasIcon = Boolean(resource.iconUrl); - const clickProps = getLauncherResourceSelectProps(onSelect); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; return (
void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherResourceGrid({ @@ -21,7 +21,11 @@ export function LauncherResourceGrid({ key={resource.launcherResourceKey} resource={resource} showLabels={showLabels} - onSelect={() => onResourceSelect(resource)} + onSelect={ + onResourceSelect + ? () => onResourceSelect(resource) + : undefined + } /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceList.tsx b/src/components/resource-launcher/LauncherResourceList.tsx index e64e2b7b0..555d112a3 100644 --- a/src/components/resource-launcher/LauncherResourceList.tsx +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -6,7 +6,7 @@ import { LauncherResourceRow } from "./LauncherResourceRow"; type LauncherResourceListProps = { resources: LauncherResource[]; showLabels: boolean; - onResourceSelect: (resource: LauncherResource) => void; + onResourceSelect?: (resource: LauncherResource) => void; }; export function LauncherResourceList({ @@ -23,7 +23,11 @@ export function LauncherResourceList({ resource={resource} showLabels={showLabels} isLast={index === resources.length - 1} - onSelect={() => onResourceSelect(resource)} + onSelect={ + onResourceSelect + ? () => onResourceSelect(resource) + : undefined + } /> ))}
diff --git a/src/components/resource-launcher/LauncherResourceRow.tsx b/src/components/resource-launcher/LauncherResourceRow.tsx index 34558de51..eca882c56 100644 --- a/src/components/resource-launcher/LauncherResourceRow.tsx +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -11,7 +11,7 @@ type LauncherResourceRowProps = { resource: LauncherResource; showLabels: boolean; isLast?: boolean; - onSelect: () => void; + onSelect?: () => void; }; export function LauncherResourceRow({ @@ -21,19 +21,21 @@ export function LauncherResourceRow({ onSelect }: LauncherResourceRowProps) { const hasTags = showLabels && resource.labels.length > 0; - const clickProps = getLauncherResourceSelectProps(onSelect); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; return (
+
{viewOptions.map((option) => { const isSelected = activeViewId === option.value; return ( diff --git a/src/components/resource-launcher/ResourceLauncher.tsx b/src/components/resource-launcher/ResourceLauncher.tsx index 10b9df847..6503de169 100644 --- a/src/components/resource-launcher/ResourceLauncher.tsx +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -32,7 +32,6 @@ import { useToast } from "@app/hooks/useToast"; import { useEnvContext } from "@app/hooks/useEnvContext"; import type { LauncherGroup, - LauncherResource, LauncherViewConfig, LauncherViewRecord } from "@server/routers/launcher/types"; @@ -51,12 +50,13 @@ import { import { useDebouncedCallback } from "use-debounce"; import type { Selectedsite } from "@app/components/site-selector"; 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 { LauncherGroupList } from "./LauncherGroupList"; import { LauncherRefreshButton } from "./LauncherRefreshButton"; import { LauncherSettingsMenu } from "./LauncherSettingsMenu"; import { LauncherSortButton } from "./LauncherSortButton"; -import { LauncherResourcePanel } from "./LauncherResourcePanel"; import { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; @@ -98,11 +98,11 @@ export default function ResourceLauncher({ const [searchInputResetKey, setSearchInputResetKey] = useState(0); const [saveDialogOpen, setSaveDialogOpen] = useState(false); - const [selectedResource, setSelectedResource] = - useState(null); const [newViewName, setNewViewName] = useState(""); const [saveOrgWide, setSaveOrgWide] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const configRef = useRef(config); configRef.current = config; const searchInputRef = useRef(config.query); @@ -392,6 +392,90 @@ export default function ResourceLauncher({ }); }; + const savedViewTabs = views.map((view) => ({ + viewId: view.viewId, + name: view.name + })); + + const renderToolbarSearch = (searchClassName: string) => ( +
+ + { + const value = event.currentTarget.value; + searchInputRef.current = value; + debouncedNavigateSearch(activeViewIdRef.current, value); + }} + placeholder={t("resourceLauncherSearchPlaceholder")} + className="pl-8" + type="search" + /> +
+ ); + + const renderToolbarActions = () => ( + <> + + + applyConfigPatch({ + siteIds: sites.map((site) => site.siteId) + }) + } + onLabelsChange={(labels) => + applyConfigPatch({ + labelIds: labels.map((label) => label.labelId) + }) + } + /> + + applyConfigPatch({ + order: config.order === "asc" ? "desc" : "asc" + }) + } + /> + { + if (!isDefaultView) { + deleteViewMutation.mutate(activeViewId); + } + }} + /> + + + ); + + const renderToolbarViews = () => ( + + ); + return (
-
-
-
-
- - { - const value = event.currentTarget.value; - searchInputRef.current = value; - debouncedNavigateSearch( - activeViewIdRef.current, - value - ); - }} - placeholder={t( - "resourceLauncherSearchPlaceholder" - )} - className="pl-8" - type="search" - /> -
- ({ - viewId: view.viewId, - name: view.name - }))} - onSelectView={selectView} - /> + {isDesktop ? ( +
+ {renderToolbarSearch("w-64")} +
+ {renderToolbarViews()}
-
- - - applyConfigPatch({ - siteIds: sites.map((site) => site.siteId) - }) - } - onLabelsChange={(labels) => - applyConfigPatch({ - labelIds: labels.map( - (label) => label.labelId - ) - }) - } - /> - - applyConfigPatch({ - order: - config.order === "asc" ? "desc" : "asc" - }) - } - /> - { - if (!isDefaultView) { - deleteViewMutation.mutate(activeViewId); - } - }} - /> - +
+ {renderToolbarActions()}
-
+ ) : ( +
+
+ {renderToolbarActions()} +
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarViews()} +
+
+ )} - - { - if (!open) { - setSelectedResource(null); - } - }} - resource={selectedResource} - orgId={orgId} - isAdmin={isAdmin} /> From 7fb95e1726918f78b284bf2c5f269965b29e0878 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:15:43 -0400 Subject: [PATCH 18/21] restore script --- server/setup/scriptsSqlite/1.13.0.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index a67b0fa14..c4b49495f 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -271,7 +271,6 @@ export default async function migration() { `ALTER TABLE 'sites' DROP COLUMN 'remoteSubnets';` ).run(); -<<<<<<< Updated upstream // get all of the siteResources and set the aliasAddress to 100.96.128.x starting at .8 const siteResourcesForAlias = db .prepare( @@ -279,15 +278,6 @@ export default async function migration() { ) .all() as { siteResourceId: number; -======= - // Associate clients with site resources based on their previous site access - // Get all client-site associations from the renamed clientSitesAssociationsCache table - const clientSiteAssociations = db - .prepare(`SELECT clientId, siteId FROM 'clientSitesAssociationsCache'`) - .all() as { - clientId: number; - siteId: number; ->>>>>>> Stashed changes }[]; const updateAliasAddress = db.prepare( @@ -345,7 +335,6 @@ export default async function migration() { `INSERT INTO 'clientSiteResources' ('clientId', 'siteResourceId') VALUES (?, ?)` ); -<<<<<<< Updated upstream // create a clientSiteResourcesAssociationsCache entry for each existing association as well const insertClientSiteResourceCache = db.prepare( `INSERT INTO 'clientSiteResourcesAssociationsCache' ('clientId', 'siteResourceId') VALUES (?, ?)` @@ -356,13 +345,6 @@ export default async function migration() { const siteResources = getSiteResources.all( association.siteId ) as { -======= - // For each client-site association, find all site resources for that site - for (const association of clientSiteAssociations) { - const siteResources = db - .prepare(`SELECT siteResourceId FROM 'siteResources' WHERE siteId = ?`) - .all(association.siteId) as { ->>>>>>> Stashed changes siteResourceId: number; }[]; @@ -370,25 +352,15 @@ export default async function migration() { for (const siteResource of siteResources) { insertClientSiteResource.run( association.clientId, -<<<<<<< Updated upstream siteResource.siteResourceId ); insertClientSiteResourceCache.run( association.clientId, -======= ->>>>>>> Stashed changes siteResource.siteResourceId ); } } - // Get all clients for niceId population (used later) - const clients = db - .prepare(`SELECT clientId FROM 'clients'`) - .all() as { - clientId: number; - }[]; - // Associate existing site resources with their org's admin role const siteResourcesWithOrg = db .prepare(`SELECT siteResourceId, orgId FROM 'siteResources'`) From 023110b3410afb20d7bfc01e2de51915c923deb4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 15:18:49 -0400 Subject: [PATCH 19/21] update en-us --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 1621bb8d4..7c14597a8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3568,7 +3568,7 @@ "resourceLauncherLayout": "Layout", "resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutList": "List", - "resourceLauncherShowLabels": "Show Label Tags", + "resourceLauncherShowLabels": "Show Labels", "resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowRecents": "Show Recents", "resourceLauncherDeleteView": "Delete View", From 663244fa3a71c66314fd1d5bc5241517dd387565 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 16:17:27 -0400 Subject: [PATCH 20/21] support labels in list alises --- .../resource/listUserResourceAliases.ts | 166 ++++++++++++++---- 1 file changed, 127 insertions(+), 39 deletions(-) diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 75dc91166..dab8afc23 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -5,8 +5,12 @@ import { userSiteResources, roleSiteResources, userOrgRoles, - userOrgs + userOrgs, + labels, + siteResourceLabels } from "@server/db"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -19,13 +23,33 @@ import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; +const labelFilterQuerySchema = z + .preprocess((val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (Array.isArray(val)) { + return val; + } + if (typeof val === "string") { + return val.split(","); + } + return undefined; + }, z.array(z.string())) + .optional() + .catch([]); + function userResourceAliasesCacheKey( orgId: string, userId: string, page: number, - pageSize: number + pageSize: number, + includeLabels: boolean, + labelFilter: string[] ) { - return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`; + const labelsKey = + labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all"; + return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`; } const listUserResourceAliasesParamsSchema = z.strictObject({ @@ -56,43 +80,35 @@ const listUserResourceAliasesQuerySchema = z.strictObject({ type: "integer", default: 1, description: "Page number to retrieve" - }) + }), + includeLabels: z + .enum(["true", "false"]) + .optional() + .default("false") + .transform((val) => val === "true") + .openapi({ + type: "boolean", + default: false, + description: + "When true, include label names for each alias in the items field" + }), + labels: labelFilterQuerySchema.openapi({ + type: "array", + description: + "Filter by resource labels. A resource matches when it has any of the given labels (OR)." + }) }); +export type UserResourceAliasItem = { + alias: string; + labels: string[]; +}; + export type ListUserResourceAliasesResponse = PaginatedResponse<{ aliases: string[]; + items?: UserResourceAliasItem[]; }>; -// registry.registerPath({ -// method: "get", -// path: "/org/{orgId}/user-resource-aliases", -// description: -// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.", -// tags: [OpenAPITags.PrivateResource], -// request: { -// params: z.object({ -// orgId: z.string() -// }), -// query: listUserResourceAliasesQuerySchema -// }, -// responses: { -// 200: { -// description: "Successful response", -// content: { -// "application/json": { -// schema: z.object({ -// data: z.record(z.string(), z.any()).nullable(), -// success: z.boolean(), -// error: z.boolean(), -// message: z.string(), -// status: z.number() -// }) -// } -// } -// } -// } -// }); - export async function listUserResourceAliases( req: Request, res: Response, @@ -110,7 +126,12 @@ export async function listUserResourceAliases( ) ); } - const { page, pageSize } = parsedQuery.data; + const { + page, + pageSize, + includeLabels, + labels: labelFilter + } = parsedQuery.data; const parsedParams = listUserResourceAliasesParamsSchema.safeParse( req.params @@ -149,7 +170,9 @@ export async function listUserResourceAliases( orgId, userId, page, - pageSize + pageSize, + includeLabels, + labelFilter ?? [] ); const cachedData: ListUserResourceAliasesResponse | undefined = await cache.get(cacheKey); @@ -204,6 +227,7 @@ export async function listUserResourceAliases( if (accessibleSiteResourceIds.length === 0) { const data: ListUserResourceAliasesResponse = { aliases: [], + ...(includeLabels ? { items: [] } : {}), pagination: { total: 0, pageSize, @@ -224,18 +248,44 @@ export async function listUserResourceAliases( }); } - const whereClause = and( + const isLabelFeatureEnabled = await isLicensedOrSubscribed( + orgId, + tierMatrix.labels + ); + + const whereConditions = [ eq(siteResources.orgId, orgId), eq(siteResources.enabled, true), or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")), isNotNull(siteResources.alias), ne(siteResources.alias, ""), inArray(siteResources.siteResourceId, accessibleSiteResourceIds) - ); + ]; + + if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { + whereConditions.push( + inArray( + siteResources.siteResourceId, + db + .select({ id: siteResourceLabels.siteResourceId }) + .from(siteResourceLabels) + .innerJoin( + labels, + eq(labels.labelId, siteResourceLabels.labelId) + ) + .where(inArray(labels.name, labelFilter)) + ) + ); + } + + const whereClause = and(...whereConditions); const baseSelect = () => db - .select({ alias: siteResources.alias }) + .select({ + alias: siteResources.alias, + siteResourceId: siteResources.siteResourceId + }) .from(siteResources) .where(whereClause); @@ -251,8 +301,46 @@ export async function listUserResourceAliases( const aliases = rows.map((r) => r.alias as string); + let items: UserResourceAliasItem[] | undefined; + if (includeLabels) { + const siteResourceIdList = rows.map((r) => r.siteResourceId); + + let labelsForSiteResources: Array<{ + name: string; + siteResourceId: number; + }> = []; + + if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { + labelsForSiteResources = await db + .select({ + name: labels.name, + siteResourceId: siteResourceLabels.siteResourceId + }) + .from(labels) + .innerJoin( + siteResourceLabels, + eq(siteResourceLabels.labelId, labels.labelId) + ) + .where( + inArray( + siteResourceLabels.siteResourceId, + siteResourceIdList + ) + ) + .orderBy(asc(siteResourceLabels.siteResourceLabelId)); + } + + items = rows.map((row) => ({ + alias: row.alias as string, + labels: labelsForSiteResources + .filter((l) => l.siteResourceId === row.siteResourceId) + .map((l) => l.name) + })); + } + const data: ListUserResourceAliasesResponse = { aliases, + ...(items !== undefined ? { items } : {}), pagination: { total: totalCount, pageSize, From e35878ee5511120d08f33c0ffb7ccceaba1ec1d9 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 1 Jul 2026 19:59:38 -0400 Subject: [PATCH 21/21] add caching --- .../launcher/launcherResourceAccess.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts index 60365d496..8fe5e8ef8 100644 --- a/server/routers/launcher/launcherResourceAccess.ts +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -19,6 +19,7 @@ import { userResources, userSiteResources } from "@server/db"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { @@ -57,7 +58,18 @@ export type AccessibleIds = { siteResourceIds: number[]; }; -export async function resolveAccessibleIds( +const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; + +function launcherAccessibleIdsCacheKey( + orgId: string, + userId: string, + roleIds: number[] +) { + const rolesKey = [...roleIds].sort((a, b) => a - b).join(","); + return `launcherAccessibleIds:${orgId}:${userId}:${rolesKey}`; +} + +async function resolveAccessibleIdsUncached( orgId: string, userId: string, userRoleIds: number[] @@ -158,6 +170,26 @@ export async function resolveAccessibleIds( }; } +export async function resolveAccessibleIds( + orgId: string, + userId: string, + userRoleIds: number[] +): Promise { + const cacheKey = launcherAccessibleIdsCacheKey(orgId, userId, userRoleIds); + const cached = await cache.get(cacheKey); + if (cached) { + return cached; + } + + const result = await resolveAccessibleIdsUncached( + orgId, + userId, + userRoleIds + ); + await cache.set(cacheKey, result, LAUNCHER_ACCESSIBLE_IDS_TTL_SEC); + return result; +} + function searchPattern(query: string) { return `%${query.trim()}%`; }