show sites and labels for non admins

This commit is contained in:
miloschwartz
2026-07-01 12:17:36 -04:00
parent 75f481bc3d
commit 87e1a509ce
10 changed files with 583 additions and 16 deletions

View File

@@ -468,6 +468,18 @@ authenticated.get(
launcher.listLauncherResources
);
authenticated.get(
"/org/:orgId/launcher/sites",
verifyOrgAccess,
launcher.listLauncherSites
);
authenticated.get(
"/org/:orgId/launcher/labels",
verifyOrgAccess,
launcher.listLauncherLabels
);
authenticated.get(
"/org/:orgId/launcher/views",
verifyOrgAccess,

View File

@@ -1,6 +1,8 @@
export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels";
export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView";

View File

@@ -39,10 +39,12 @@ import {
import {
LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery,
type LauncherGroup,
type LauncherLabel,
type LauncherListQuery,
type LauncherResource,
type LauncherSiteInfo,
parseIdListParam
} from "./types";
@@ -1138,3 +1140,261 @@ export async function listLauncherResourcesForUser(
total
};
}
function buildSiteNameSearchCondition(query: string) {
if (!query.trim()) {
return undefined;
}
const pattern = searchPattern(query.toLowerCase());
return or(
like(sql`LOWER(${sites.name})`, pattern),
like(sql`LOWER(${sites.niceId})`, pattern)
);
}
function buildLabelNameSearchCondition(query: string) {
if (!query.trim()) {
return undefined;
}
const pattern = searchPattern(query.toLowerCase());
return like(sql`LOWER(${labels.name})`, pattern);
}
async function collectAccessibleSites(
orgId: string,
accessible: AccessibleIds,
siteNameSearch?: ReturnType<typeof buildSiteNameSearchCondition>
): Promise<Map<number, SiteGroupRow>> {
const siteCountMap = new Map<number, SiteGroupRow>();
if (accessible.resourceIds.length > 0) {
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
];
if (siteNameSearch) {
publicConditions.push(siteNameSearch);
}
const publicRows = await db
.select({
siteId: sites.siteId,
name: sites.name,
type: sites.type,
online: sites.online,
itemCount: countDistinct(resources.resourceId)
})
.from(targets)
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(and(...publicConditions))
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of publicRows) {
const existing = siteCountMap.get(row.siteId);
if (existing) {
existing.itemCount += Number(row.itemCount);
} else {
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
});
}
}
}
if (accessible.siteResourceIds.length > 0) {
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
];
if (siteNameSearch) {
siteConditions.push(siteNameSearch);
}
const siteRows = await db
.select({
siteId: sites.siteId,
name: sites.name,
type: sites.type,
online: sites.online,
itemCount: countDistinct(siteResources.siteResourceId)
})
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(and(...siteConditions))
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
for (const row of siteRows) {
const existing = siteCountMap.get(row.siteId);
if (existing) {
existing.itemCount += Number(row.itemCount);
} else {
siteCountMap.set(row.siteId, {
siteId: row.siteId,
name: row.name,
type: row.type,
online: row.online,
itemCount: Number(row.itemCount)
});
}
}
}
return siteCountMap;
}
async function collectAccessibleLabels(
orgId: string,
accessible: AccessibleIds,
labelNameSearch?: ReturnType<typeof buildLabelNameSearchCondition>
): Promise<Map<number, LauncherLabel>> {
const labelMap = new Map<number, LauncherLabel>();
if (!(await labelsEnabled(orgId))) {
return labelMap;
}
if (accessible.resourceIds.length > 0) {
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
publicConditions.push(labelNameSearch);
}
const labeledPublic = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(resourceLabels)
.innerJoin(labels, eq(resourceLabels.labelId, labels.labelId))
.innerJoin(
resources,
eq(resourceLabels.resourceId, resources.resourceId)
)
.where(and(...publicConditions))
.groupBy(labels.labelId, labels.name, labels.color);
for (const row of labeledPublic) {
labelMap.set(row.labelId, {
labelId: row.labelId,
name: row.name,
color: row.color
});
}
}
if (accessible.siteResourceIds.length > 0) {
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
siteConditions.push(labelNameSearch);
}
const labeledSite = await db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color
})
.from(siteResourceLabels)
.innerJoin(labels, eq(siteResourceLabels.labelId, labels.labelId))
.innerJoin(
siteResources,
eq(
siteResourceLabels.siteResourceId,
siteResources.siteResourceId
)
)
.where(and(...siteConditions))
.groupBy(labels.labelId, labels.name, labels.color);
for (const row of labeledSite) {
labelMap.set(row.labelId, {
labelId: row.labelId,
name: row.name,
color: row.color
});
}
}
return labelMap;
}
export async function listAccessibleLauncherSitesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherFilterListQuery
): Promise<{ sites: LauncherSiteInfo[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteNameSearch = buildSiteNameSearchCondition(query.query);
const siteCountMap = await collectAccessibleSites(
orgId,
accessible,
siteNameSearch
);
const sites: LauncherSiteInfo[] = Array.from(siteCountMap.values())
.map((row) => ({
siteId: row.siteId,
name: row.name,
type: row.type,
online: row.online
}))
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
const total = sites.length;
const offset = (query.page - 1) * query.pageSize;
return {
sites: sites.slice(offset, offset + query.pageSize),
total
};
}
export async function listAccessibleLauncherLabelsForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherFilterListQuery
): Promise<{ labels: LauncherLabel[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const labelNameSearch = buildLabelNameSearchCondition(query.query);
const labelMap = await collectAccessibleLabels(
orgId,
accessible,
labelNameSearch
);
const labelsList = Array.from(labelMap.values()).sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
);
const total = labelsList.length;
const offset = (query.page - 1) * query.pageSize;
return {
labels: labelsList.slice(offset, offset + query.pageSize),
total
};
}

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

@@ -93,6 +93,40 @@ export type ListLauncherViewsResponse = {
views: LauncherViewRecord[];
};
export const launcherFilterListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()
.int()
.positive()
.optional()
.catch(500)
.default(500),
page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("")
});
export type LauncherFilterListQuery = z.infer<
typeof launcherFilterListQuerySchema
>;
export type ListLauncherSitesResponse = {
sites: LauncherSiteInfo[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export type ListLauncherLabelsResponse = {
labels: LauncherLabel[];
pagination: {
total: number;
page: number;
pageSize: number;
};
};
export const launcherListQuerySchema = z.strictObject({
pageSize: z.coerce
.number()

View File

@@ -8,7 +8,7 @@ import {
CommandItem,
CommandList
} from "@app/components/ui/command";
import { orgQueries } from "@app/lib/queries";
import { launcherQueries, orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
@@ -27,6 +27,7 @@ type LabelsFilterSelectorProps = {
onToggle: (label: LabelFilterOption) => void;
onClear?: () => void;
showClear?: boolean;
scope?: "org" | "launcher";
};
export function LabelsFilterSelector({
@@ -34,19 +35,33 @@ export function LabelsFilterSelector({
isSelected,
onToggle,
onClear,
showClear = false
showClear = false,
scope = "org"
}: LabelsFilterSelectorProps) {
const t = useTranslations();
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
const { data: labels = [] } = useQuery(
orgQueries.labels({
const orgLabelsQuery = useQuery({
...orgQueries.labels({
orgId,
query: debouncedQuery,
perPage: 500
})
);
}),
enabled: scope === "org"
});
const launcherLabelsQuery = useQuery({
...launcherQueries.labels({
orgId,
query: debouncedQuery,
perPage: 500
}),
enabled: scope === "launcher"
});
const labels =
scope === "launcher"
? (launcherLabelsQuery.data ?? [])
: (orgLabelsQuery.data ?? []);
return (
<Command shouldFilter={false}>

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

@@ -17,7 +17,7 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries";
import { launcherQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { ChevronsUpDown, Funnel } from "lucide-react";
@@ -44,12 +44,37 @@ export function LauncherFilterPopover({
const [labelsOpen, setLabelsOpen] = useState(false);
const { data: labels = [] } = useQuery(
orgQueries.labels({
launcherQueries.labels({
orgId,
perPage: 500
})
);
const { data: sites = [] } = useQuery(
launcherQueries.sites({
orgId,
perPage: 500
})
);
const resolvedSelectedSites: Selectedsite[] = useMemo(
() =>
selectedSites.map((selected) => {
const found = sites.find(
(site) => site.siteId === selected.siteId
);
return found
? {
siteId: found.siteId,
name: found.name,
type: found.type,
online: found.online
}
: selected;
}),
[sites, selectedSites]
);
const selectedLabelIds = useMemo(
() => new Set(selectedLabels.map((label) => label.labelId)),
[selectedLabels]
@@ -98,7 +123,7 @@ export function LauncherFilterPopover({
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
resolvedSelectedSites,
t
)}
</span>
@@ -111,8 +136,9 @@ export function LauncherFilterPopover({
>
<MultiSitesSelector
orgId={orgId}
selectedSites={selectedSites}
selectedSites={resolvedSelectedSites}
onSelectionChange={onSitesChange}
scope="launcher"
/>
</PopoverContent>
</Popover>
@@ -145,6 +171,7 @@ export function LauncherFilterPopover({
>
<LabelsFilterSelector
orgId={orgId}
scope="launcher"
isSelected={(label) =>
selectedLabelIds.has(label.labelId)
}

View File

@@ -48,7 +48,9 @@ import type { ListResourcePoliciesResponse } from "@server/routers/resource/type
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type {
ListLauncherGroupsResponse,
ListLauncherLabelsResponse,
ListLauncherResourcesResponse,
ListLauncherSitesResponse,
ListLauncherViewsResponse,
LauncherListQuery,
LauncherViewConfig
@@ -1190,6 +1192,72 @@ export const launcherQueries = {
return res.data.data.views;
}
}),
sites: ({
orgId,
query,
perPage = 500
}: {
orgId: string;
query?: string;
perPage?: number;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"LAUNCHER",
"SITES",
{ query, perPage }
] as const,
queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({
pageSize: perPage.toString()
});
if (query?.trim()) {
sp.set("query", query);
}
const res = await meta!.api.get<
AxiosResponse<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,