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 5ba319dca..0129c1159 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1411,6 +1411,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", @@ -2087,6 +2088,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", @@ -2314,6 +2316,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", @@ -3567,6 +3570,55 @@ "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", + "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", + "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 Labels", + "resourceLauncherShowSiteTags": "Show Site Tags", + "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", + "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", + "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/auth/actions.ts b/server/auth/actions.ts index f081574e7..e47f94d74 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -179,7 +179,8 @@ export enum ActionsEnum { setResourcePolicyPincode = "setResourcePolicyPincode", setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth", setResourcePolicyWhitelist = "setResourcePolicyWhitelist", - setResourcePolicyRules = "setResourcePolicyRules" + setResourcePolicyRules = "setResourcePolicyRules", + createOrgWideLauncherView = "createOrgWideLauncherView" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 765c07724..683c572b6 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -222,6 +222,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", { @@ -1575,6 +1589,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 fe827149a..28797bcd9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -223,6 +223,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", { @@ -1551,6 +1565,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 31ed385d8..5d770c064 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"; @@ -463,6 +464,54 @@ 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/sites", + verifyOrgAccess, + launcher.listLauncherSites +); + +authenticated.get( + "/org/:orgId/launcher/labels", + verifyOrgAccess, + launcher.listLauncherLabels +); + +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..d124263d5 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -159,6 +159,7 @@ authenticated.get( verifyApiKeyOrgAccess, resource.getUserResources ); + // 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..593bad304 --- /dev/null +++ b/server/routers/launcher/createLauncherView.ts @@ -0,0 +1,101 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +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 { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +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 = req.userOrgId; + const userId = req.user!.userId; + + 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) + ) + ); + } + + if (parsed.data.orgWide) { + const canCreateOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + if (!canCreateOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + } + + 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..beaf1b433 --- /dev/null +++ b/server/routers/launcher/deleteLauncherView.ts @@ -0,0 +1,86 @@ +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 { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +export async function deleteLauncherView( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const orgId = req.userOrgId; + const userId = req.user!.userId; + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + + if (!orgId || !Number.isFinite(viewId)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid request parameters" + ) + ); + } + + 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 canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { + 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..c12767ce1 --- /dev/null +++ b/server/routers/launcher/formatLauncherAccess.ts @@ -0,0 +1,172 @@ +import { formatEndpoint, parseEndpoint } from "@server/lib/ip"; + +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; + exitNodeEndpoint?: string | null; +}; + +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; +}; + +function formatTcpUdpResourceAccess( + exitNodeEndpoint: string | null | undefined, + proxyPort: number | null +): LauncherAccessFields { + if (proxyPort == null) { + return { + accessDisplay: "", + accessCopyValue: "", + accessUrl: null + }; + } + + if (!exitNodeEndpoint?.trim()) { + const port = proxyPort.toString(); + return { + accessDisplay: port, + accessCopyValue: port, + accessUrl: null + }; + } + + 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: "", + 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..939bf74bc --- /dev/null +++ b/server/routers/launcher/index.ts @@ -0,0 +1,9 @@ +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"; +export { deleteLauncherView } from "./deleteLauncherView"; diff --git a/server/routers/launcher/launcherResourceAccess.ts b/server/routers/launcher/launcherResourceAccess.ts new file mode 100644 index 000000000..8fe5e8ef8 --- /dev/null +++ b/server/routers/launcher/launcherResourceAccess.ts @@ -0,0 +1,1436 @@ +import { db } from "@server/db"; +import { + exitNodes, + labels, + launcherViews, + resourceLabels, + resources, + rolePolicies, + roleResources, + roles, + roleSiteResources, + siteNetworks, + siteResourceLabels, + siteResources, + sites, + targets, + userOrgRoles, + userPolicies, + 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 { + and, + asc, + countDistinct, + eq, + inArray, + isNull, + like, + or, + sql +} from "drizzle-orm"; +import { + formatPublicResourceAccess, + formatSiteResourceAccess +} from "./formatLauncherAccess"; +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"; + +const effectiveResourcePolicyId = sql< + number | null +>`coalesce(${resources.resourcePolicyId}, ${resources.defaultResourcePolicyId})`; + +export type AccessibleIds = { + resourceIds: number[]; + siteResourceIds: number[]; +}; + +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[] +): 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) + ]) + ) + }; +} + +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()}%`; +} + +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 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), + name: row.name, + groupType: "site" as const, + itemCount: row.itemCount, + siteType: row.type, + siteOnline: row.online + }) + ); + + 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" + }); + 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, + userRoleIds: number[], + query: LauncherListQuery +): Promise<{ groups: LauncherGroup[]; total: number }> { + 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, + niceId: resources.niceId, + 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, + 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), + 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, + exitNodeEndpoint: row.exitNodeEndpoint + }); + + result.push({ + launcherResourceKey: key, + resourceType: "public", + resourceId: row.resourceId, + niceId: row.niceId, + 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, + niceId: siteResources.niceId, + 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, + niceId: row.niceId, + 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 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 +): 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, + userRoleIds: number[], + query: LauncherListQuery & { groupKey: string } +): Promise<{ resources: LauncherResource[]; total: number }> { + 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 parsedSiteId = + query.groupBy === "site" && + query.groupKey !== LAUNCHER_NO_SITE_GROUP_KEY + ? Number.parseInt(query.groupKey, 10) + : Number.NaN; + const siteIdFilter = Number.isFinite(parsedSiteId) + ? parsedSiteId + : 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); + } else if (query.groupBy === "site") { + items = filterResourcesBySite(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 + }; +} + +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/listLauncherGroups.ts b/server/routers/launcher/listLauncherGroups.ts new file mode 100644 index 000000000..a57e5864a --- /dev/null +++ b/server/routers/launcher/listLauncherGroups.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 { listLauncherGroupsForUser } from "./launcherResourceAccess"; +import { launcherListQuerySchema } from "./types"; + +export async function listLauncherGroups( + 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 = launcherListQuerySchema.safeParse(req.query); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsed.error) + ) + ); + } + + const { groups, total } = await listLauncherGroupsForUser( + orgId, + userId, + req.userOrgRoleIds ?? [], + 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/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/listLauncherResources.ts b/server/routers/launcher/listLauncherResources.ts new file mode 100644 index 000000000..94f18b864 --- /dev/null +++ b/server/routers/launcher/listLauncherResources.ts @@ -0,0 +1,72 @@ +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { fromZodError } from "zod-validation-error"; +import { z } from "zod"; +import { 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 = req.userOrgId; + const userId = req.user!.userId; + + 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, + req.userOrgRoleIds ?? [], + 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/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/listLauncherViews.ts b/server/routers/launcher/listLauncherViews.ts new file mode 100644 index 000000000..e176bae46 --- /dev/null +++ b/server/routers/launcher/listLauncherViews.ts @@ -0,0 +1,73 @@ +import { db, launcherViews } from "@server/db"; +import { response } from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; + +function mapViewRow( + row: typeof launcherViews.$inferSelect +): LauncherViewRecord { + return { + viewId: row.viewId, + orgId: row.orgId, + userId: row.userId, + name: row.name, + config: launcherViewConfigSchema.parse(JSON.parse(row.config)), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + isOrgWide: row.userId == null + }; +} + +export async function listLauncherViews( + 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 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..0a252d74d --- /dev/null +++ b/server/routers/launcher/types.ts @@ -0,0 +1,165 @@ +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"), + 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; + niceId: string; + 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 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() + .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..9450da153 --- /dev/null +++ b/server/routers/launcher/updateLauncherView.ts @@ -0,0 +1,157 @@ +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 { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +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 = req.userOrgId; + const userId = req.user!.userId; + const viewId = Number.parseInt( + getFirstString(req.params.viewId) ?? "", + 10 + ); + + 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 [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 canManageOrgWide = await checkUserActionPermission( + ActionsEnum.createOrgWideLauncherView, + req + ); + + if (!isPersonalView && !(isOrgWideView && canManageOrgWide)) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have permission to update this view" + ) + ); + } + + if (parsed.data.orgWide === true && !canManageOrgWide) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + if ( + parsed.data.orgWide === false && + isOrgWideView && + !canManageOrgWide + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } + + 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/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, 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 fc806acc1..b76b77b7b 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,9 +1,9 @@ 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 { 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,12 +13,14 @@ 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; - const env = pullEnv(); if (!orgId) { redirect(`/`); @@ -40,12 +42,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 +56,40 @@ export default async function OrgPage(props: OrgPageProps) { } } catch (e) {} + 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/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/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..3c9410e43 --- /dev/null +++ b/src/components/LabelsFilterSelector.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { launcherQueries, 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; + scope?: "org" | "launcher"; +}; + +export function LabelsFilterSelector({ + orgId, + isSelected, + onToggle, + onClear, + showClear = false, + scope = "org" +}: LabelsFilterSelectorProps) { + const t = useTranslations(); + const [labelSearchQuery, setlabelsSearchQuery] = useState(""); + const [debouncedQuery] = useDebounce(labelSearchQuery, 150); + + 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 ( + + + + {t("labelsNotFound")} + + {showClear && onClear && ( + + {t("accessFilterClear")} + + )} + {labels.map((label) => ( + { + onToggle(label); + }} + className="flex items-center gap-2" + > + +
+ {label.name} + + ))} + + + + ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index dd0ef3d2f..32d4d620c 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; @@ -64,11 +68,21 @@ export async function Layout({ navItems={navItems} showSidebar={showSidebar} showTopBar={showTopBar} + launcherMode={launcherMode} + showViewAsAdmin={showViewAsAdmin} /> )} {/* 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/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/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/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; } 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/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/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/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/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/LauncherFilterPopover.tsx b/src/components/resource-launcher/LauncherFilterPopover.tsx new file mode 100644 index 000000000..5a0e425f9 --- /dev/null +++ b/src/components/resource-launcher/LauncherFilterPopover.tsx @@ -0,0 +1,208 @@ +"use client"; + +import { + formatMultiSitesSelectorLabel, + MultiSitesSelector +} from "@app/components/multi-site-selector"; +import { + formatLabelsSelectorLabel, + LABEL_COLORS, + type SelectedLabel +} from "@app/components/labels-selector"; +import { LabelsFilterSelector } from "@app/components/LabelsFilterSelector"; +import { Button } from "@app/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { launcherQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { ChevronsUpDown, Funnel } from "lucide-react"; +import { useMemo, 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); + + const { data: labels = [] } = useQuery( + 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] + ); + + 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 ( + + + + + +
+
+

{t("sites")}

+ + + + + + + + +
+
+

{t("labels")}

+ + + + + + + selectedLabelIds.has(label.labelId) + } + onToggle={(label) => { + if ( + selectedLabelIds.has(label.labelId) + ) { + onLabelsChange( + selectedLabels.filter( + (item) => + item.labelId !== + label.labelId + ) + ); + } else { + onLabelsChange([ + ...selectedLabels, + label + ]); + } + }} + showClear={selectedLabels.length > 0} + onClear={() => { + onLabelsChange([]); + }} + /> + + +
+
+
+
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupList.tsx b/src/components/resource-launcher/LauncherGroupList.tsx new file mode 100644 index 000000000..bb3418f8d --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupList.tsx @@ -0,0 +1,155 @@ +"use client"; + +import type { LauncherActiveViewId } from "@app/lib/launcherLocalStorage"; +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"; +import { Loader2 } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { LauncherEmptyState } from "./LauncherEmptyState"; +import { LauncherGroupSection } from "./LauncherGroupSection"; + +type LauncherGroupListProps = { + orgId: string; + activeViewId: LauncherActiveViewId; + config: LauncherViewConfig; + initialGroups: LauncherGroup[]; + groupsPagination: { + total: number; + page: number; + pageSize: number; + }; + resourcesByGroupKey: Record; + onClearFilters?: () => void; + onResourceSelect?: (resource: LauncherResource) => 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, + onClearFilters, + onResourceSelect +}: LauncherGroupListProps) { + const loadMoreRef = useRef(null); + + 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, isFetching } = + useInfiniteQuery({ + ...launcherQueries.groups(orgId, groupFilters), + initialData: { + pages: [ + { + groups: initialGroups, + pagination: groupsPagination + } + ], + pageParams: [1] + }, + refetchOnMount: false + }); + + const groups = data?.pages.flatMap((page) => page.groups) ?? []; + + useEffect(() => { + const node = loadMoreRef.current; + if (!node || !hasNextPage) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage]); + + if (groups.length === 0) { + if (isFetching) { + return ( +
+ +
+ ); + } + + return ( + + ); + } + + return ( +
+ {groups.map((group) => { + const groupResources = resourcesByGroupKey[group.groupKey]; + + return ( + + ); + })} +
+ {isFetchingNextPage ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherGroupSection.tsx b/src/components/resource-launcher/LauncherGroupSection.tsx new file mode 100644 index 000000000..49b648032 --- /dev/null +++ b/src/components/resource-launcher/LauncherGroupSection.tsx @@ -0,0 +1,201 @@ +"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, + 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"; +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; + initialResources?: LauncherResource[]; + initialResourcesPagination?: { + total: number; + page: number; + pageSize: number; + }; + defaultOpen?: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherGroupSection({ + orgId, + activeViewId, + group, + config, + initialResources, + initialResourcesPagination, + defaultOpen = true, + onResourceSelect +}: 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 hasInitialResources = initialResources !== undefined; + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = + useInfiniteQuery({ + ...launcherQueries.resources(orgId, { + query: config.query, + groupBy: config.groupBy, + groupKey: group.groupKey, + siteIds: config.siteIds, + labelIds: config.labelIds, + sort_by: config.sortBy, + order: config.order, + pageSize: 20 + }), + 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 || !isOpen) { + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && !isFetchingNextPage) { + void fetchNextPage(); + } + }, + { rootMargin: "200px" } + ); + + observer.observe(node); + return () => observer.disconnect(); + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isOpen]); + + const groupTitle = + group.groupKey === LAUNCHER_UNLABELED_GROUP_KEY + ? t("resourceLauncherUnlabeled") + : group.groupKey === LAUNCHER_NO_SITE_GROUP_KEY + ? t("resourceLauncherNoSite") + : group.name; + + return ( + + + + + {showInitialLoader ? ( +
+ +
+ ) : 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/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/LauncherResourceAccess.tsx b/src/components/resource-launcher/LauncherResourceAccess.tsx new file mode 100644 index 000000000..81a867e17 --- /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..ac891e84b --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceCard.tsx @@ -0,0 +1,69 @@ +"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 { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; + +type LauncherResourceCardProps = { + resource: LauncherResource; + showLabels: boolean; + onSelect?: () => void; +}; + +export function LauncherResourceCard({ + resource, + showLabels, + onSelect +}: LauncherResourceCardProps) { + const hasIcon = Boolean(resource.iconUrl); + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; + + 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..0e69bee04 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceGrid.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceCard } from "./LauncherResourceCard"; + +type LauncherResourceGridProps = { + resources: LauncherResource[]; + showLabels: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherResourceGrid({ + resources, + showLabels, + onResourceSelect +}: LauncherResourceGridProps) { + return ( +
+ {resources.map((resource) => ( + onResourceSelect(resource) + : undefined + } + /> + ))} +
+ ); +} 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..555d112a3 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceList.tsx @@ -0,0 +1,36 @@ +"use client"; + +import type { LauncherResource } from "@server/routers/launcher/types"; +import { LauncherResourceRow } from "./LauncherResourceRow"; + +type LauncherResourceListProps = { + resources: LauncherResource[]; + showLabels: boolean; + onResourceSelect?: (resource: LauncherResource) => void; +}; + +export function LauncherResourceList({ + resources, + showLabels, + onResourceSelect +}: LauncherResourceListProps) { + return ( +
+
+ {resources.map((resource, index) => ( + onResourceSelect(resource) + : undefined + } + /> + ))} +
+
+ ); +} 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 new file mode 100644 index 000000000..eca882c56 --- /dev/null +++ b/src/components/resource-launcher/LauncherResourceRow.tsx @@ -0,0 +1,68 @@ +"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 { getLauncherResourceSelectProps } from "./useLauncherResourceAction"; + +type LauncherResourceRowProps = { + resource: LauncherResource; + showLabels: boolean; + isLast?: boolean; + onSelect?: () => void; +}; + +export function LauncherResourceRow({ + resource, + showLabels, + isLast = false, + onSelect +}: LauncherResourceRowProps) { + const hasTags = showLabels && resource.labels.length > 0; + const clickProps = onSelect + ? getLauncherResourceSelectProps(onSelect) + : null; + + return ( +
+ + + + {resource.name} + + + + + {hasTags ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/resource-launcher/LauncherSettingsMenu.tsx b/src/components/resource-launcher/LauncherSettingsMenu.tsx new file mode 100644 index 000000000..41143cd10 --- /dev/null +++ b/src/components/resource-launcher/LauncherSettingsMenu.tsx @@ -0,0 +1,133 @@ +"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 }) + } + /> +
+
+ + {!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..98d8f7289 --- /dev/null +++ b/src/components/resource-launcher/LauncherViewTabs.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + 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; + onResetView: () => void; +}; + +export function LauncherSaveViewMenu({ + isDefaultView, + isAdmin, + isOrgWideView, + hasUnsavedChanges, + onSaveToCurrent, + onSaveAsNew, + onSaveForEveryone, + onMakePersonal, + onResetView +}: LauncherSaveViewMenuProps) { + const t = useTranslations(); + + return ( + + + + + + {hasUnsavedChanges ? ( + <> + + {t("resourceLauncherResetView")} + + + + ) : null} + {!isDefaultView && (isAdmin || !isOrgWideView) ? ( + + {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..6503de169 --- /dev/null +++ b/src/components/resource-launcher/ResourceLauncher.tsx @@ -0,0 +1,581 @@ +"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 { 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, + parseLauncherUrlState, + serializeLauncherUrlState +} from "@app/lib/launcherUrlState"; +import { useToast } from "@app/hooks/useToast"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import type { + LauncherGroup, + LauncherViewConfig, + LauncherViewRecord +} from "@server/routers/launcher/types"; +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, + 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 { 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 { LauncherSaveViewMenu, LauncherViewTabs } from "./LauncherViewTabs"; +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, + views, + activeViewId, + config, + savedConfig, + groups, + groupsPagination, + resourcesByGroupKey +}: ResourceLauncherProps) { + const t = useTranslations(); + const { toast } = useToast(); + const { env } = useEnvContext(); + 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); + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + 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); + const activeViewIdRef = useRef(activeViewId); + activeViewIdRef.current = activeViewId; + + 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) => { + const params = serializeLauncherUrlState({ + viewId, + config: nextConfig + }); + navigate({ searchParams: params }); + }, + [navigate] + ); + + const debouncedNavigateSearch = useDebouncedCallback( + (viewId: LauncherActiveViewId, query: string) => { + navigateToConfig(viewId, { ...configRef.current, query }); + }, + 300 + ); + + const selectView = useCallback( + (viewId: LauncherActiveViewId) => { + writeLauncherLastView(orgId, viewId); + const baseConfig = getLauncherUrlBaseConfig(viewId, views); + navigateToConfig(viewId, baseConfig); + }, + [navigateToConfig, orgId, 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 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) => { + writeLauncherLastView(orgId, view.viewId); + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + 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) => { + const params = serializeLauncherUrlState({ + viewId: view.viewId, + config: view.config + }); + navigate({ searchParams: params, replace: true }); + router.refresh(); + toast({ + title: t("resourceLauncherViewSaved"), + description: t("resourceLauncherViewSavedDescription") + }); + }, + onError: (error) => { + toast({ + variant: "destructive", + title: t("resourceLauncherViewSaveFailed"), + description: formatAxiosError( + error, + t("resourceLauncherViewSaveFailedDescription") + ) + }); + } + }); + + const deleteViewMutation = useMutation({ + mutationFn: async (viewId: number) => { + await api.delete(`/org/${orgId}/launcher/views/${viewId}`); + }, + onSuccess: () => { + 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") + }); + }, + 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 + }; + navigateToConfig(activeViewIdRef.current, nextConfig); + }, + [navigateToConfig] + ); + + const handleClearFilters = useCallback(() => { + searchInputRef.current = ""; + setSearchInputResetKey((key) => key + 1); + navigateToConfig(activeViewIdRef.current, { + ...configRef.current, + query: "", + siteIds: [], + labelIds: [] + }); + }, [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 || (isOrgWideView && !isAdmin)) { + 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 + }); + }; + + 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 ( +
+ + + {isDesktop ? ( +
+ {renderToolbarSearch("w-64")} +
+ {renderToolbarViews()} +
+
+ {renderToolbarActions()} +
+
+ ) : ( +
+
+ {renderToolbarActions()} +
+ {renderToolbarSearch("w-full")} +
+ {renderToolbarViews()} +
+
+ )} + + + + + + + + {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..4c7d081dd --- /dev/null +++ b/src/components/resource-launcher/useLauncherResourceAction.ts @@ -0,0 +1,127 @@ +"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, + container?: EventTarget | null +): boolean { + if (!(target instanceof Element)) { + return false; + } + + 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, 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 +) { + 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/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()}`; +} 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/launcherUrlState.ts b/src/lib/launcherUrlState.ts new file mode 100644 index 000000000..cfe8e26c4 --- /dev/null +++ b/src/lib/launcherUrlState.ts @@ -0,0 +1,278 @@ +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" + | "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", + "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 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 (!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..14fa1d3da 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -46,6 +46,20 @@ 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, + ListLauncherLabelsResponse, + ListLauncherResourcesResponse, + ListLauncherSitesResponse, + ListLauncherViewsResponse, + 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; @@ -1166,3 +1180,123 @@ export const domainQueries = { refetchInterval: durationToMs(10, "seconds") }) }; + +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; + } + }), + 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, + queryFn: async ({ pageParam = 1, signal, meta }) => { + const sp = buildLauncherSearchParams(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 = buildLauncherSearchParams(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; + } + }) +};