From 2203ebf723d3114cc0453ed90767a75202022700 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 28 Apr 2026 21:27:11 -0700 Subject: [PATCH 1/4] show user idp in devices --- server/routers/client/getClient.ts | 36 +++++++++++++++-- server/routers/client/listUserDevices.ts | 7 ++++ .../[orgId]/settings/clients/user/page.tsx | 3 ++ src/components/ClientInfoCard.tsx | 20 +++++++++- src/components/UserDevicesTable.tsx | 39 +++++++++++++------ 5 files changed, 90 insertions(+), 15 deletions(-) diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index a05fdef42..c97612b07 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, olms, users } from "@server/db"; +import { db, idp, idpOidcConfig, olms, users } from "@server/db"; import { clients, currentFingerprint } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -236,6 +236,9 @@ export type GetClientResponse = NonNullable< lastSeen: number | null; } | null; posture: PostureData | null; + userType: string | null; + idpName: string | null; + idpVariant: string | null; }; registry.registerPath({ @@ -337,6 +340,30 @@ export async function getClient( : maskPostureDataWithPlaceholder(rawPosture) : null; + let userType: string | null = null; + let idpName: string | null = null; + let idpVariant: string | null = null; + + if (client.clients.userId) { + const [idpRow] = await db + .select({ + userType: users.type, + idpName: idp.name, + idpVariant: idpOidcConfig.variant + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where(eq(users.userId, client.clients.userId)) + .limit(1); + + if (idpRow) { + userType = idpRow.userType; + idpName = idpRow.idpName; + idpVariant = idpRow.idpVariant; + } + } + const data: GetClientResponse = { ...client.clients, name: clientName, @@ -347,7 +374,10 @@ export async function getClient( userName: client.user?.name ?? null, userUsername: client.user?.username ?? null, fingerprint: fingerprintData, - posture: postureData + posture: postureData, + userType, + idpName, + idpVariant }; return response(res, { diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index d793faf09..567eb0d6b 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -3,6 +3,8 @@ import { clients, currentFingerprint, db, + idp, + idpOidcConfig, olms, orgs, roleClients, @@ -165,6 +167,9 @@ function queryUserDevicesBase() { userId: clients.userId, username: users.username, userEmail: users.email, + userType: users.type, + idpName: idp.name, + idpVariant: idpOidcConfig.variant, niceId: clients.niceId, agent: olms.agent, approvalState: clients.approvalState, @@ -184,6 +189,8 @@ function queryUserDevicesBase() { .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 23fba583a..880019177 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -96,6 +96,9 @@ export default async function ClientsPage(props: ClientsPageProps) { userId: client.userId, username: client.username, userEmail: client.userEmail, + userType: client.userType ?? null, + idpName: client.idpName ?? null, + idpVariant: client.idpVariant ?? null, niceId: client.niceId, agent: client.agent, archived: Boolean(client.archived), diff --git a/src/components/ClientInfoCard.tsx b/src/components/ClientInfoCard.tsx index 7f55a46cd..4815c85fb 100644 --- a/src/components/ClientInfoCard.tsx +++ b/src/components/ClientInfoCard.tsx @@ -8,6 +8,7 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; +import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { useTranslations } from "next-intl"; @@ -36,7 +37,24 @@ export default function SiteInfoCard({}: ClientInfoCardProps) { {userDisplayName ? t("user") : t("identifier")} - {userDisplayName || client.niceId} +
+ {userDisplayName || client.niceId} + {userDisplayName && + (client.userType ?? "internal") !== + "internal" && ( + + )} +
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 88e495406..0a130cc16 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -35,6 +35,7 @@ import { useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; @@ -52,6 +53,9 @@ export type ClientRow = { userId: string | null; username: string | null; userEmail: string | null; + userType: string | null; + idpName: string | null; + idpVariant: string | null; niceId: string; agent: string | null; approvalState: "approved" | "pending" | "denied" | null; @@ -370,17 +374,30 @@ export default function UserDevicesTable({ cell: ({ row }) => { const r = row.original; return r.userId ? ( - - - +
+ + + + {(r.userType ?? "internal") !== "internal" && ( + + )} +
) : ( "-" ); From a44100c2bdd2550ee34f190c31c894292f8ab3da Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 22:19:03 -0700 Subject: [PATCH 2/4] Handle deleting client and orphaning resources --- server/routers/site/deleteSite.ts | 30 +++++++----- .../siteResource/createSiteResource.ts | 10 +++- .../siteResource/listAllSiteResourcesByOrg.ts | 46 ++++++++++++++----- .../siteResource/updateSiteResource.ts | 10 +++- src/components/ClientResourcesTable.tsx | 9 ++-- 5 files changed, 77 insertions(+), 28 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 344f6b4e3..32735c639 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Site, siteNetworks, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -77,17 +77,25 @@ export async function deleteSite( .where(eq(siteNetworks.siteId, siteId)); // loop through them - for (const network of await networks) { - const [siteResource] = await trx - .select() - .from(siteResources) - .where(eq(siteResources.networkId, network.networkId)); - if (siteResource) { - await rebuildClientAssociationsFromSiteResource( - siteResource, - trx + const updatedSiteResources = await trx + .select() + .from(siteResources) + .where( + inArray( + siteResources.networkId, + networks.map((n) => n.networkId) + ) + ); + for (const siteResource of updatedSiteResources) { + rebuildClientAssociationsFromSiteResource( + siteResource, + trx + ).catch((error) => { + logger.error( + "Failed to rebuild client associations from site resource:", + error ); - } + }); } // get the newt on the site by querying the newt table for siteId diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index cec1c8b35..b3238796d 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -47,6 +47,7 @@ const createSiteResourceSchema = z ssl: z.boolean().optional(), // only used for http mode scheme: z.enum(["http", "https"]).optional(), siteIds: z.array(z.int()), + siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided // proxyPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(), destination: z.string().min(1), @@ -187,7 +188,8 @@ export async function createSiteResource( const { name, niceId, - siteIds, + siteIds: siteIdsInput, + siteId, mode, scheme, // proxyPort, @@ -208,6 +210,12 @@ export async function createSiteResource( subdomain } = parsedBody.data; + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } + if (mode == "http") { const hasHttpFeature = await isLicensedOrSubscribed( orgId, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 0a6166755..c7099de40 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ */ function aggCol(column: any) { if (DB_TYPE === "sqlite") { + // json_group_array will include NULLs for left-joined missing rows; + // we filter them out in transformSiteResourceRow keeping arrays aligned. return sql`json_group_array(${column})`; } - return sql`array_agg(${column})`; + return sql`COALESCE(array_agg(${column}) FILTER (WHERE ${sites.siteId} IS NOT NULL), '{}')`; } /** @@ -112,16 +114,36 @@ function transformSiteResourceRow(row: any) { if (DB_TYPE !== "sqlite") { return row; } + const siteIdsRaw = JSON.parse(row.siteIds) as (number | null)[]; + const siteNamesRaw = JSON.parse(row.siteNames) as (string | null)[]; + const siteNiceIdsRaw = JSON.parse(row.siteNiceIds) as (string | null)[]; + const siteAddressesRaw = JSON.parse(row.siteAddresses) as (string | null)[]; + const siteOnlinesRaw = JSON.parse(row.siteOnlines) as (0 | 1 | null)[]; + + // When a site resource has no associated sites (left join produced no + // matches), the aggregated arrays will contain a single NULL entry. Strip + // those out, keeping the parallel arrays aligned by siteId presence. + const siteIds: number[] = []; + const siteNames: string[] = []; + const siteNiceIds: string[] = []; + const siteAddresses: (string | null)[] = []; + const siteOnlines: boolean[] = []; + for (let i = 0; i < siteIdsRaw.length; i++) { + if (siteIdsRaw[i] == null) continue; + siteIds.push(siteIdsRaw[i] as number); + siteNames.push((siteNamesRaw[i] ?? "") as string); + siteNiceIds.push((siteNiceIdsRaw[i] ?? "") as string); + siteAddresses.push(siteAddressesRaw[i] ?? null); + siteOnlines.push(siteOnlinesRaw[i] === 1); + } + return { ...row, - siteNames: JSON.parse(row.siteNames) as string[], - siteNiceIds: JSON.parse(row.siteNiceIds) as string[], - siteIds: JSON.parse(row.siteIds) as number[], - siteAddresses: JSON.parse(row.siteAddresses) as (string | null)[], - // SQLite stores booleans as 0/1 integers - siteOnlines: (JSON.parse(row.siteOnlines) as (0 | 1)[]).map( - (v) => v === 1 - ) as boolean[] + siteNames, + siteNiceIds, + siteIds, + siteAddresses, + siteOnlines }; } @@ -158,11 +180,11 @@ function querySiteResourcesBase() { siteOnlines: aggCol(sites.online) }) .from(siteResources) - .innerJoin( + .leftJoin( siteNetworks, eq(siteResources.networkId, siteNetworks.networkId) ) - .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .leftJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .groupBy(siteResources.siteResourceId); } @@ -215,6 +237,8 @@ export async function listAllSiteResourcesByOrg( const conditions = [and(eq(siteResources.orgId, orgId))]; if (siteId != null) { + // Keep inner joins here: filtering by a specific site implies the + // resource must have at least one matching site. const resourcesForSite = db .select({ id: siteResources.siteResourceId }) .from(siteResources) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d2bb44a45..462cb1c2b 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -44,6 +44,7 @@ const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), siteIds: z.array(z.int()), + siteId: z.int().positive().optional(), // niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(), niceId: z .string() @@ -196,7 +197,8 @@ export async function updateSiteResource( const { siteResourceId } = parsedParams.data; const { name, - siteIds, // because it can change + siteIds: siteIdsInput, // because it can change + siteId, niceId, mode, scheme, @@ -217,6 +219,12 @@ export async function updateSiteResource( subdomain } = parsedBody.data; + // Backward compatibility: merge deprecated siteId into siteIds array + const siteIds = [...siteIdsInput]; + if (siteId !== undefined && !siteIds.includes(siteId)) { + siteIds.push(siteId); + } + // Check if site resource exists const [existingSiteResource] = await db .select() diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index a772fb576..5c5906ad5 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -37,11 +37,8 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { useEffect, useMemo, useState, useTransition } from "react"; - import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; -import { orgQueries } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; import type { PaginationState } from "@tanstack/react-table"; import { ControlledDataTable } from "./ui/controlled-data-table"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; @@ -206,7 +203,11 @@ export default function ClientResourcesTable({ const { siteNames, siteNiceIds, orgId } = resourceRow; if (!siteNames || siteNames.length === 0) { - return -; + return ( + + {t("noSites", { defaultValue: "No sites" })} + + ); } if (siteNames.length === 1) { From 1d0a92c83e9ea04aef263e6b396724bc0bf640c2 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 22:22:06 -0700 Subject: [PATCH 3/4] Its in the transaction so we wait --- server/routers/site/deleteSite.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 32735c639..10ecbbf1e 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -87,15 +87,10 @@ export async function deleteSite( ) ); for (const siteResource of updatedSiteResources) { - rebuildClientAssociationsFromSiteResource( + await rebuildClientAssociationsFromSiteResource( siteResource, trx - ).catch((error) => { - logger.error( - "Failed to rebuild client associations from site resource:", - error - ); - }); + ); } // get the newt on the site by querying the newt table for siteId From 3bcbeb24f34e44af6d9fefda863b53780301a387 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 22:27:35 -0700 Subject: [PATCH 4/4] Query the right column --- server/lib/blueprints/clientResources.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index c19ea3245..1b2ec2ef7 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -125,12 +125,12 @@ export async function updateClientResources( const existingSiteIds = existingResource?.networkId ? await trx - .select({ siteId: sites.siteId }) + .select({ siteId: siteNetworks.siteId }) .from(siteNetworks) .where(eq(siteNetworks.networkId, existingResource.networkId)) : []; - let allSites: { siteId: number }[] = []; + const allSites: { siteId: number }[] = []; if (resourceData.site) { let siteSingle; const resourceSiteId = resourceData.site;