Merge pull request #3375 from fosrl/resource-launcher

Resource launcher
This commit is contained in:
Owen Schwartz
2026-07-01 21:19:19 -04:00
committed by GitHub
62 changed files with 6698 additions and 1269 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.

View File

@@ -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",

View File

@@ -179,7 +179,8 @@ export enum ActionsEnum {
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules"
setResourcePolicyRules = "setResourcePolicyRules",
createOrgWideLauncherView = "createOrgWideLauncherView"
}
export async function checkUserActionPermission(

View File

@@ -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<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -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<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode

View File

@@ -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,

View File

@@ -159,6 +159,7 @@ authenticated.get(
verifyApiKeyOrgAccess,
resource.getUserResources
);
// Site Resource endpoints
authenticated.put(
"/org/:orgId/site-resource",

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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
};
}

View File

@@ -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";

File diff suppressed because it is too large Load Diff

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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<typeof launcherViewConfigSchema>;
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<typeof launcherListQuerySchema>;
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 };

View File

@@ -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<any> {
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"
)
);
}
}

View File

@@ -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,

View File

@@ -0,0 +1,9 @@
import { Loader2 } from "lucide-react";
export default function OrgPageLoading() {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}

View File

@@ -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<Record<string, string>>;
};
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 (
<UserProvider user={user}>
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
{overview && <MemberResourcesPortal orgId={orgId} />}
<Layout
orgId={orgId}
orgs={orgs}
navItems={[]}
showSidebar={false}
launcherMode
showViewAsAdmin={isAdminOrOwner}
>
{overview && launcherData ? (
<ResourceLauncher
orgId={orgId}
isAdmin={isAdminOrOwner}
views={launcherData.views}
activeViewId={launcherData.activeViewId}
config={launcherData.config}
savedConfig={launcherData.savedConfig}
groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination}
resourcesByGroupKey={launcherData.resourcesByGroupKey}
/>
) : null}
</Layout>
</UserProvider>
);

View File

@@ -107,7 +107,15 @@ export default async function Page(props: {
}
if (targetOrgId) {
return <RedirectToOrg targetOrgId={targetOrgId} />;
const targetOrg = orgs.find((org) => org.orgId === targetOrgId);
return (
<RedirectToOrg
targetOrgId={targetOrgId}
isAdminOrOwner={Boolean(
targetOrg?.isAdmin || targetOrg?.isOwner
)}
/>
);
}
return (

View File

@@ -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<string[]>(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"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{draftValues.length > 0 && (
<CommandItem
onSelect={() => {
setDraftValues([]);
}}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<Checkbox
className="pointer-events-none shrink-0"
checked={draftSet.has(label.name)}
aria-hidden
tabIndex={-1}
/>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<LabelsFilterSelector
orgId={orgId}
isSelected={(label) => draftSet.has(label.name)}
onToggle={(label) => {
toggle(label.name);
}}
showClear={draftValues.length > 0}
onClear={() => {
setDraftValues([]);
}}
/>
</PopoverContent>
</Popover>
</div>

View File

@@ -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 (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{showClear && onClear && (
<CommandItem
onSelect={onClear}
className="text-muted-foreground"
>
{t("accessFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.labelId}
value={label.name}
onSelect={() => {
onToggle(label);
}}
className="flex items-center gap-2"
>
<Checkbox
className="pointer-events-none shrink-0"
checked={isSelected(label)}
aria-hidden
tabIndex={-1}
/>
<div
className="size-2 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}

View File

@@ -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 && <LayoutHeader showTopBar={showTopBar} />}
{showHeader && (
<LayoutHeader
showTopBar={showTopBar}
launcherMode={launcherMode}
orgId={orgId}
orgs={orgs}
showViewAsAdmin={showViewAsAdmin}
/>
)}
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">

View File

@@ -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<string>("");
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) {
<div className="relative z-10 px-6 py-2">
<div className="container mx-auto max-w-12xl">
<div className="h-16 flex items-center justify-between">
<div className="flex items-center gap-2">
<Link href="/" className="flex items-center">
<div className="flex items-center gap-5 min-w-0">
<Link
href="/"
className="flex items-center shrink-0"
>
<BrandingLogo
width={logoWidth}
height={logoHeight}
/>
</Link>
{/* {build === "saas" && (
<Badge variant="secondary">Cloud Beta</Badge>
)} */}
{launcherMode ? (
<>
<LauncherOrgSelector
orgId={orgId}
orgs={orgs}
/>
{showViewAsAdmin && orgId ? (
<Button
variant="text"
size="sm"
className="p-0"
asChild
>
<Link href={`/${orgId}/settings`}>
{t(
"resourceLauncherViewAsAdmin"
)}
</Link>
</Button>
) : null}
</>
) : null}
</div>
{showTopBar && (

View File

@@ -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 (
<div className="shrink-0 md:hidden sticky top-0 z-50">
<div className="h-16 flex items-center px-2">
<div className="flex items-center gap-4">
{showSidebar && (
{showMobileNav && (
<div>
<Sheet
open={isMobileMenuOpen}
@@ -69,24 +85,24 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
{launcherMode ? (
<>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
{showViewAsAdmin && orgId ? (
<div className="px-3">
<div className="mb-1">
<Link
href="/admin"
className={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"
)}
href={`/${orgId}/settings`}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
@@ -94,25 +110,95 @@ export function LayoutMobileMenu({
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<Server className="h-4 w-4" />
<Settings className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"serverAdmin"
"resourceLauncherViewAsAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(false)
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
</div>
) : null}
</>
) : (
<>
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div className="mb-1">
<Link
href={`/${orgId}`}
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<SquareMousePointer className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"resourceLauncherTitle"
)}
</span>
</Link>
</div>
)}
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">
<Link
href="/admin"
className={
mobileNavLinkClassName
}
onClick={() =>
setIsMobileMenuOpen(
false
)
}
>
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground mr-3">
<Server className="h-4 w-4" />
</span>
<span className="flex-1">
{t(
"serverAdmin"
)}
</span>
</Link>
</div>
)}
<SidebarNav
sections={navItems}
onItemClick={() =>
setIsMobileMenuOpen(
false
)
}
/>
</div>
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
</>
)}
</SheetContent>
</Sheet>
</div>

View File

@@ -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 (
<div
className={cn(
@@ -152,6 +165,46 @@ export function LayoutSidebar({
/>
<div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-3">
{!isAdminPage &&
isSettingsPage &&
canViewResourceLauncher &&
orgId && (
<div
className={cn(
"shrink-0",
isSidebarCollapsed ? "mb-4" : "mb-1"
)}
>
<Link
href={`/${orgId}`}
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("resourceLauncherTitle")
: undefined
}
>
<span
className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground",
!isSidebarCollapsed && "mr-3"
)}
>
<SquareMousePointer className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span className="flex-1">
{t("resourceLauncherTitle")}
</span>
)}
</Link>
</div>
)}
{!isAdminPage && user.serverAdmin && (
<div
className={cn(

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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 <Sheet {...props}>{children}</Sheet>;
};
const SidePanelTrigger = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetTrigger className={className} {...props}>
{children}
</SheetTrigger>
);
};
const SidePanelClose = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetClose className={className} {...props}>
{children}
</SheetClose>
);
};
const SidePanelContent = ({
className,
children,
...props
}: SidePanelProps) => {
const isDesktop = useMediaQuery(desktop);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
className={cn(
"fixed z-50 flex min-h-0 flex-col gap-4 overflow-hidden border bg-card px-6 pt-6 pb-1 shadow-lg transition ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
"data-[state=closed]:duration-200 data-[state=open]:duration-300",
isDesktop
? "inset-y-0 right-0 h-full w-2/5 border-l"
: "inset-x-0 bottom-0 max-h-[85dvh] w-full border-t",
className
)}
{...props}
onOpenAutoFocus={(e) => e.preventDefault()}
>
{children}
</SheetPrimitive.Content>
</SheetPortal>
);
};
const SidePanelDescription = ({
className,
children,
...props
}: SidePanelProps) => {
return (
<SheetDescription className={className} {...props}>
{children}
</SheetDescription>
);
};
const SidePanelHeader = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetHeader
className={cn("shrink-0 -mx-6 px-6", className)}
{...props}
>
{children}
</SheetHeader>
);
};
const SidePanelTitle = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetTitle className={className} {...props}>
{children}
</SheetTitle>
);
};
const SidePanelBody = ({ className, children, ...props }: SidePanelProps) => {
return (
<div
className={cn(
"relative min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden px-0",
className
)}
{...props}
>
<div className="space-y-4">{children}</div>
<div
className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent"
aria-hidden
/>
</div>
);
};
const SidePanelFooter = ({ className, children, ...props }: SidePanelProps) => {
return (
<SheetFooter
className={cn(
"-mt-4 shrink-0 border-t border-border py-4 -mx-6 gap-2 px-6 bg-card",
className
)}
{...props}
>
{children}
</SheetFooter>
);
};
export {
SidePanel,
SidePanelBody,
SidePanelClose,
SidePanelContent,
SidePanelDescription,
SidePanelFooter,
SidePanelHeader,
SidePanelTitle,
SidePanelTrigger
};

View File

@@ -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",

View File

@@ -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

View File

@@ -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 (
<button
type="button"
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground",
className
)}
onClick={(event) => {
event.stopPropagation();
void navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<Check className="size-4 text-green-500" />
) : (
<Copy className="size-4" />
)}
<span className="sr-only">{t("copyText")}</span>
</button>
);
}

View File

@@ -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 (
<div
className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0"
aria-hidden
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="flex min-w-0 flex-col gap-2.5 rounded-xl border border-border/60 bg-muted/20 p-4"
>
<div className="flex items-center gap-5">
<div className="size-10 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-3/5 rounded bg-muted/60" />
<div className="h-3 w-2/5 rounded bg-muted/40" />
</div>
</div>
</div>
))}
</div>
);
}
function GhostResourceList() {
return (
<div className="flex w-full flex-col" aria-hidden>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className={cn(
"flex items-center gap-4 px-4 py-3",
index < 2 && "border-b border-border/60"
)}
>
<div className="size-8 shrink-0 rounded-lg bg-muted/60" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="h-3.5 w-2/5 rounded bg-muted/60" />
<div className="h-3 w-1/4 rounded bg-muted/40" />
</div>
</div>
))}
</div>
);
}
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 (
<div className="relative w-full overflow-hidden rounded-xl border border-dashed border-border">
<div className="pointer-events-none absolute inset-0 opacity-50">
{layout === "grid" ? (
<GhostResourceGrid />
) : (
<GhostResourceList />
)}
</div>
<div className="relative flex min-h-56 flex-col items-center justify-center gap-4 px-6 py-12 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<Icon className="size-5 text-muted-foreground" />
</div>
<div className="max-w-md space-y-1.5">
<h3 className="text-base font-semibold text-foreground">
{isNoResults
? t("resourceLauncherEmptyStateNoResultsTitle")
: t("resourceLauncherEmptyStateTitle")}
</h3>
<p className="text-sm text-muted-foreground">
{isNoResults
? trimmedQuery
? t(
"resourceLauncherEmptyStateNoResultsWithQuery",
{ query: trimmedQuery }
)
: t(
"resourceLauncherEmptyStateNoResultsDescription"
)
: t("resourceLauncherEmptyStateDescription")}
</p>
</div>
{isNoResults && onClearFilters ? (
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
>
{t("clearAllFilters")}
</Button>
) : null}
</div>
</div>
);
}

View File

@@ -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 (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<Funnel className="size-4" />
<span className="sr-only">
{t("resourceLauncherFilter")}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="flex flex-col gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold">{t("sites")}</p>
<Popover open={sitesOpen} onOpenChange={setSitesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
resolvedSelectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<MultiSitesSelector
orgId={orgId}
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold">{t("labels")}</p>
<Popover open={labelsOpen} onOpenChange={setLabelsOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between font-normal",
selectedLabels.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatLabelsSelectorLabel(
resolvedSelectedLabels,
t
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<LabelsFilterSelector
orgId={orgId}
scope="launcher"
isSelected={(label) =>
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([]);
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<string, LauncherGroupResources>;
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<HTMLDivElement | null>(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 (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
</div>
);
}
return (
<LauncherEmptyState
variant={
hasActiveLauncherFilters(config) ? "noResults" : "empty"
}
layout={config.layout}
query={config.query}
onClearFilters={onClearFilters}
/>
);
}
return (
<div className="flex flex-col gap-2.5">
{groups.map((group) => {
const groupResources = resourcesByGroupKey[group.groupKey];
return (
<LauncherGroupSection
key={group.groupKey}
orgId={orgId}
activeViewId={activeViewId}
group={group}
config={config}
initialResources={groupResources?.resources}
initialResourcesPagination={groupResources?.pagination}
onResourceSelect={onResourceSelect}
/>
);
})}
<div ref={loadMoreRef} className="h-4" />
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
);
}

View File

@@ -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<HTMLDivElement | null>(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 (
<Collapsible
open={isOpen}
onOpenChange={handleOpenChange}
className="flex w-full flex-col gap-2.5"
>
<LauncherGroupTrigger
group={group}
title={groupTitle}
isOpen={isOpen}
/>
<CollapsibleContent className="w-full">
{showInitialLoader ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : resources.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
{t("resourceLauncherNoResourcesInGroup")}
</p>
) : config.layout === "grid" ? (
<LauncherResourceGrid
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
) : (
<LauncherResourceList
resources={resources}
showLabels={config.showLabels}
onResourceSelect={onResourceSelect}
/>
)}
<div
ref={loadMoreRef}
className={cn("h-4", !hasNextPage && "hidden")}
/>
{isFetchingNextPage ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</CollapsibleContent>
</Collapsible>
);
}

View File

@@ -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 (
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: group.labelColor }}
/>
);
}
if (group.groupType === "site") {
if (
(group.siteType === "newt" || group.siteType === "wireguard") &&
typeof group.siteOnline === "boolean"
) {
return (
<span
className={
group.siteOnline
? "size-2 shrink-0 rounded-full bg-green-500"
: "size-2 shrink-0 rounded-full bg-neutral-500"
}
/>
);
}
return <span className="size-2 shrink-0 rounded-full bg-neutral-500" />;
}
return null;
}
export function LauncherGroupTrigger({
group,
title,
isOpen
}: LauncherGroupTriggerProps) {
return (
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-md bg-accent px-4 py-2.5 text-left transition-colors cursor-pointer">
{group.groupType === "site" || group.groupType === "label" ? (
<LauncherGroupStatusDot group={group} />
) : null}
<span className="flex min-w-0 items-center gap-2.5 text-sm font-semibold text-foreground">
<span className="truncate">
{title} ({group.itemCount})
</span>
{isOpen ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
) : (
<ChevronLeft className="size-4 shrink-0 text-muted-foreground" />
)}
</span>
</CollapsibleTrigger>
);
}

View File

@@ -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<number>();
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<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(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<HTMLElement>(
"[data-measure-label]"
);
const overflowNode = measure.querySelector<HTMLElement>(
"[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 (
<div
ref={containerRef}
className={cn("relative min-w-0 w-full", className)}
>
<div
className={cn(
"flex items-center gap-1",
variant === "single-row" ? "flex-nowrap" : "flex-wrap"
)}
>
{visibleLabels.map((label) => (
<LabelBadge
key={label.labelId}
name={label.name}
color={label.color}
displayOnly
className="shrink-0"
/>
))}
{overflowLabels.length > 0 ? (
<LabelOverflowBadge
labels={overflowLabels.map((label) => ({
color: label.color,
name: label.name
}))}
displayOnly
className="shrink-0"
/>
) : null}
</div>
{variant === "wrap" ? (
<div
ref={measureRef}
className="pointer-events-none invisible absolute left-0 top-0 flex flex-wrap items-center gap-1"
aria-hidden
>
{labels.map((label) => (
<span key={label.labelId} data-measure-label>
<LabelBadge
name={label.name}
color={label.color}
displayOnly
className="shrink-0"
/>
</span>
))}
<span
data-measure-overflow
className="inline-flex shrink-0"
>
<LabelOverflowBadge labels={labels} displayOnly />
</span>
</div>
) : null}
</div>
);
}

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
className="inline-flex items-center gap-1 p-0"
variant="text"
size="sm"
>
<span className="truncate max-w-[200px]">
{selectedOrg?.name ?? t("noneSelected")}
</span>
<ChevronDown className="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg border-0">
<CommandInput placeholder={t("searchPlaceholder")} />
<CommandList className="max-h-[280px]">
<CommandEmpty>{t("orgNotFound2")}</CommandEmpty>
<CommandGroup heading={t("orgs")}>
{sortedOrgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={() => {
setOpen(false);
const newPath = pathname.includes(
"/settings/"
)
? pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
)
: `/${org.orgId}`;
router.push(newPath);
}}
>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate text-sm">
{org.name}
</span>
<span className="text-xs text-muted-foreground font-mono truncate">
{org.orgId}
</span>
</div>
<Check
className={cn(
"h-4 w-4 text-primary shrink-0",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
className="shrink-0"
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">{t("refresh")}</span>
</Button>
);
}

View File

@@ -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 (
<div className="flex min-w-0 flex-1 items-center gap-2.5 max-md:min-w-[12rem] max-md:shrink-0 max-md:flex-none">
{canLink ? (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 truncate text-sm text-muted-foreground hover:underline max-md:overflow-visible max-md:whitespace-nowrap"
>
{accessDisplay}
</Link>
) : (
<span className="min-w-0 truncate text-sm text-muted-foreground max-md:overflow-visible max-md:whitespace-nowrap">
{accessDisplay}
</span>
)}
<LauncherCopyIcon text={copyValue} />
</div>
);
}
return (
<div className="flex w-full min-w-0 items-center gap-2.5">
{canLink ? (
<Link
href={href}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1 truncate text-sm text-muted-foreground hover:underline"
>
{accessDisplay}
</Link>
) : (
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{accessDisplay}
</span>
)}
<LauncherCopyIcon text={copyValue} />
</div>
);
}

View File

@@ -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 (
<div
className={cn(
"flex min-w-0 flex-col gap-2.5 overflow-hidden rounded-xl border border-border bg-background p-4",
clickProps?.className
)}
onClick={clickProps?.onClick}
onKeyDown={clickProps?.onKeyDown}
role={clickProps?.role}
tabIndex={clickProps?.tabIndex}
>
<div
className={cn(
"flex w-full items-center",
hasIcon ? "gap-5" : "gap-0"
)}
>
{hasIcon ? (
<LauncherResourceIcon
iconUrl={resource.iconUrl}
name={resource.name}
variant="grid"
/>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="truncate text-sm font-semibold text-foreground">
{resource.name}
</div>
<LauncherResourceAccess
accessDisplay={resource.accessDisplay}
accessCopyValue={resource.accessCopyValue}
accessUrl={resource.accessUrl}
variant="grid"
/>
</div>
</div>
{showLabels && resource.labels.length > 0 ? (
<LauncherLabelsRow labels={resource.labels} />
) : null}
</div>
);
}

View File

@@ -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 (
<div className="grid w-full grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 [&>*]:min-w-0">
{resources.map((resource) => (
<LauncherResourceCard
key={resource.launcherResourceKey}
resource={resource}
showLabels={showLabels}
onSelect={
onResourceSelect
? () => onResourceSelect(resource)
: undefined
}
/>
))}
</div>
);
}

View File

@@ -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 (
<img
src={iconUrl}
alt={name}
className={cn(dimension, "shrink-0 object-cover", className)}
/>
);
}
if (variant === "list") {
return (
<div
className={cn(
dimension,
"flex shrink-0 items-center justify-center text-muted-foreground",
className
)}
>
<span className="text-sm font-semibold">-</span>
</div>
);
}
return null;
}

View File

@@ -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 (
<div className="w-full max-md:overflow-x-auto max-md:overflow-y-hidden">
<div className="flex w-full flex-col max-md:w-max">
{resources.map((resource, index) => (
<LauncherResourceRow
key={resource.launcherResourceKey}
resource={resource}
showLabels={showLabels}
isLast={index === resources.length - 1}
onSelect={
onResourceSelect
? () => onResourceSelect(resource)
: undefined
}
/>
))}
</div>
</div>
);
}

View File

@@ -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 (
<SidePanel open={open} onOpenChange={onOpenChange}>
<SidePanelContent>
<SidePanelHeader>
<SidePanelTitle>{resource?.name ?? ""}</SidePanelTitle>
<SidePanelDescription>
{t("resourceLauncherResourceDetailsDescription")}
</SidePanelDescription>
</SidePanelHeader>
<SidePanelBody />
<SidePanelFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("close")}
</Button>
{isAdmin && resource ? (
<Button variant="outline" asChild>
<Link
href={getLauncherResourceAdminHref(
orgId,
resource
)}
>
{t("resourceLauncherViewAsAdmin")}
</Link>
</Button>
) : null}
</SidePanelFooter>
</SidePanelContent>
</SidePanel>
);
}

View File

@@ -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 (
<div
className={cn(
"flex items-center gap-2.5 p-4 max-md:min-w-max max-md:whitespace-nowrap",
isLast ? undefined : "border-b border-border",
clickProps?.className
)}
onClick={clickProps?.onClick}
onKeyDown={clickProps?.onKeyDown}
role={clickProps?.role}
tabIndex={clickProps?.tabIndex}
>
<LauncherResourceIcon
iconUrl={resource.iconUrl}
name={resource.name}
variant="list"
/>
<span className="shrink-0 text-sm font-semibold text-foreground">
{resource.name}
</span>
<LauncherResourceAccess
accessDisplay={resource.accessDisplay}
accessCopyValue={resource.accessCopyValue}
accessUrl={resource.accessUrl}
variant="list"
/>
{hasTags ? (
<div className="flex min-w-0 max-w-md shrink items-center justify-end gap-1 max-md:shrink-0 max-md:max-w-none md:ml-auto">
<LauncherLabelsRow
labels={resource.labels}
variant="single-row"
className="w-auto shrink-0 justify-end"
/>
</div>
) : null}
</div>
);
}

View File

@@ -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<LauncherViewConfig>) => void;
onDeleteView: () => void;
};
export function LauncherSettingsMenu({
config,
isDefaultView,
onConfigChange,
onDeleteView
}: LauncherSettingsMenuProps) {
const t = useTranslations();
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0">
<Settings className="size-4" />
<span className="sr-only">
{t("resourceLauncherSettings")}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72">
<div className="flex flex-col gap-4">
<div className="space-y-2">
<p className="text-sm font-semibold">
{t("resourceLauncherGroupBy")}
</p>
<Select
value={config.groupBy}
onValueChange={(value) =>
onConfigChange({
groupBy:
value as LauncherViewConfig["groupBy"]
})
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="site">
{t("resourceLauncherGroupBySite")}
</SelectItem>
<SelectItem value="label">
{t("resourceLauncherGroupByLabel")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<p className="text-sm font-semibold">
{t("resourceLauncherLayout")}
</p>
<Select
value={config.layout}
onValueChange={(value) =>
onConfigChange({
layout: value as LauncherViewConfig["layout"]
})
}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="grid">
{t("resourceLauncherLayoutGrid")}
</SelectItem>
<SelectItem value="list">
{t("resourceLauncherLayoutList")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-3">
<Label
htmlFor="show-labels"
className="text-sm font-semibold"
>
{t("resourceLauncherShowLabels")}
</Label>
<Switch
id="show-labels"
checked={config.showLabels}
onCheckedChange={(checked) =>
onConfigChange({ showLabels: checked })
}
/>
</div>
</div>
{!isDefaultView ? (
<Button
variant="destructive"
className="w-full rounded-xl"
onClick={onDeleteView}
>
{t("resourceLauncherDeleteView")}
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -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 (
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={onToggle}
title={
order === "asc"
? t("resourceLauncherSortAscending")
: t("resourceLauncherSortDescending")
}
>
{order === "asc" ? (
<ArrowDown01 className="size-4" />
) : (
<ArrowUp10 className="size-4" />
)}
<span className="sr-only">{t("resourceLauncherSort")}</span>
</Button>
);
}

View File

@@ -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 (
<div className="flex w-max items-center gap-2">
{viewOptions.map((option) => {
const isSelected = activeViewId === option.value;
return (
<Button
key={option.value}
type="button"
variant={
isSelected
? "squareOutlinePrimary"
: "squareOutline"
}
className={cn(
"shrink-0 min-w-30 shadow-none",
isSelected && "bg-primary/10"
)}
onClick={() => onSelectView(option.value)}
>
{option.label}
</Button>
);
})}
</div>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="shrink-0">
{hasUnsavedChanges ? (
<span className="size-2 rounded-full bg-primary mr-2" />
) : null}
{t("resourceLauncherSaveView")}
<ChevronDown className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{hasUnsavedChanges ? (
<>
<DropdownMenuItem onSelect={onResetView}>
{t("resourceLauncherResetView")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
{!isDefaultView && (isAdmin || !isOrgWideView) ? (
<DropdownMenuItem onSelect={onSaveToCurrent}>
{t("resourceLauncherSaveToCurrentView")}
</DropdownMenuItem>
) : null}
<DropdownMenuItem onSelect={onSaveAsNew}>
{t("resourceLauncherSaveAsNewView")}
</DropdownMenuItem>
{isAdmin && !isDefaultView && !isOrgWideView ? (
<DropdownMenuItem onSelect={onSaveForEveryone}>
{t("resourceLauncherSaveForEveryone")}
</DropdownMenuItem>
) : null}
{isAdmin && !isDefaultView && isOrgWideView ? (
<DropdownMenuItem onSelect={onMakePersonal}>
{t("resourceLauncherMakePersonal")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<string, LauncherGroupResources>;
};
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<LauncherViewConfig>) => {
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) => (
<div className={cn("relative shrink-0", searchClassName)}>
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
key={`${activeViewId}-${searchInputResetKey}`}
defaultValue={config.query}
onChange={(event) => {
const value = event.currentTarget.value;
searchInputRef.current = value;
debouncedNavigateSearch(activeViewIdRef.current, value);
}}
placeholder={t("resourceLauncherSearchPlaceholder")}
className="pl-8"
type="search"
/>
</div>
);
const renderToolbarActions = () => (
<>
<LauncherSaveViewMenu
isDefaultView={isDefaultView}
isAdmin={isAdmin}
isOrgWideView={isOrgWideView}
hasUnsavedChanges={hasUnsavedChanges}
onSaveToCurrent={handleSaveToCurrent}
onSaveAsNew={handleSaveAsNew}
onSaveForEveryone={handleSaveForEveryone}
onMakePersonal={handleMakePersonal}
onResetView={handleResetView}
/>
<LauncherFilterPopover
orgId={orgId}
selectedSites={selectedSites}
selectedLabels={selectedLabels}
onSitesChange={(sites) =>
applyConfigPatch({
siteIds: sites.map((site) => site.siteId)
})
}
onLabelsChange={(labels) =>
applyConfigPatch({
labelIds: labels.map((label) => label.labelId)
})
}
/>
<LauncherSortButton
order={config.order}
onToggle={() =>
applyConfigPatch({
order: config.order === "asc" ? "desc" : "asc"
})
}
/>
<LauncherSettingsMenu
config={config}
isDefaultView={isDefaultView}
onConfigChange={applyConfigPatch}
onDeleteView={() => {
if (!isDefaultView) {
deleteViewMutation.mutate(activeViewId);
}
}}
/>
<LauncherRefreshButton
onRefresh={refreshData}
isRefreshing={isRefreshing || isNavigating}
/>
</>
);
const renderToolbarViews = () => (
<LauncherViewTabs
activeViewId={activeViewId}
savedViews={savedViewTabs}
onSelectView={selectView}
/>
);
return (
<div className="flex flex-col" aria-busy={isNavigating}>
<SettingsSectionTitle
title={t("resourceLauncherTitle")}
description={t("resourceLauncherDescription")}
/>
{isDesktop ? (
<div className="mb-6 flex w-full min-w-0 items-center gap-3">
{renderToolbarSearch("w-64")}
<div className="min-w-0 flex-1 overflow-x-auto">
{renderToolbarViews()}
</div>
<div className="flex shrink-0 items-center gap-2">
{renderToolbarActions()}
</div>
</div>
) : (
<div className="mb-6 flex flex-col gap-3">
<div className="flex items-center gap-2 overflow-x-auto">
{renderToolbarActions()}
</div>
{renderToolbarSearch("w-full")}
<div className="overflow-x-auto">
{renderToolbarViews()}
</div>
</div>
)}
<LauncherGroupList
orgId={orgId}
activeViewId={activeViewId}
config={config}
initialGroups={groups}
groupsPagination={groupsPagination}
resourcesByGroupKey={resourcesByGroupKey}
onClearFilters={handleClearFilters}
/>
<Credenza open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("resourceLauncherSaveAsNewView")}
</CredenzaTitle>
<CredenzaDescription>
{t("resourceLauncherSaveAsNewViewDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-2">
<Label htmlFor="new-view-name">
{t("resourceLauncherViewNameLabel")}
</Label>
<Input
id="new-view-name"
value={newViewName}
onChange={(event) =>
setNewViewName(event.target.value)
}
/>
</div>
{isAdmin ? (
<div className="mt-4">
<CheckboxWithLabel
id="save-org-wide"
aria-describedby="save-org-wide-desc"
label={t("resourceLauncherSaveForEveryone")}
checked={saveOrgWide}
onCheckedChange={(checked) =>
setSaveOrgWide(checked === true)
}
/>
<p
id="save-org-wide-desc"
className="text-sm text-muted-foreground mt-2"
>
{t(
"resourceLauncherSaveForEveryoneDescription"
)}
</p>
</div>
) : null}
</CredenzaBody>
<CredenzaFooter>
<Button
variant="outline"
onClick={() => setSaveDialogOpen(false)}
>
{t("cancel")}
</Button>
<Button
onClick={handleCreateView}
loading={createViewMutation.isPending}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</div>
);
}

View File

@@ -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
};
}

View File

@@ -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<T>(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<LauncherActiveViewId | null>(
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<string, boolean> {
return readJson<Record<string, boolean>>(
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
});
}

View File

@@ -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;
}
}

View File

@@ -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()}`;
}

View File

@@ -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;
}

View File

@@ -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<string, LauncherGroupResources>;
};
const emptyResources: LauncherGroupResources = {
resources: [],
pagination: { total: 0, page: 1, pageSize: 20 }
};
export async function fetchLauncherPageData(
orgId: string,
searchParams: URLSearchParams,
cookieHeader: Awaited<
ReturnType<typeof import("@app/lib/api/cookies").authCookieHeader>
>
): Promise<LauncherPageData> {
let views: LauncherViewRecord[] = [];
try {
const viewsRes = await internal.get<
AxiosResponse<ListLauncherViewsResponse>
>(`/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<ListLauncherGroupsResponse>
>(`/org/${orgId}/launcher/groups?${sp.toString()}`, cookieHeader);
groups = groupsRes.data.data.groups;
groupsPagination = groupsRes.data.data.pagination;
} catch (e) {}
const resourcesByGroupKey: Record<string, LauncherGroupResources> = {};
await Promise.all(
groups.map(async (group) => {
try {
const sp = buildLauncherSearchParams(
{
...groupFilters,
groupKey: group.groupKey
},
1
);
const res = await internal.get<
AxiosResponse<ListLauncherResourcesResponse>
>(
`/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
};
}

278
src/lib/launcherUrlState.ts Normal file
View File

@@ -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}`;
}

View File

@@ -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<ListLauncherViewsResponse>
>(`/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<ListLauncherSitesResponse>
>(`/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<ListLauncherLabelsResponse>
>(`/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<ListLauncherGroupsResponse>
>(`/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<ListLauncherResourcesResponse>
>(`/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;
}
})
};