Merge pull request #2922 from fosrl/dev

1.18.0-s.2
This commit is contained in:
Owen Schwartz
2026-04-28 22:28:35 -07:00
committed by GitHub
11 changed files with 165 additions and 46 deletions

View File

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

View File

@@ -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<GetClientResponse>(res, {

View File

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

View File

@@ -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,20 @@ 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) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
// get the newt on the site by querying the newt table for siteId

View File

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

View File

@@ -98,9 +98,11 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
*/
function aggCol<T>(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<T>`json_group_array(${column})`;
}
return sql<T>`array_agg(${column})`;
return sql<T>`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<boolean[]>(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)

View File

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

View File

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

View File

@@ -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")}
</InfoSectionTitle>
<InfoSectionContent>
{userDisplayName || client.niceId}
<div className="flex flex-wrap items-center gap-2">
<span>{userDisplayName || client.niceId}</span>
{userDisplayName &&
(client.userType ?? "internal") !==
"internal" && (
<IdpTypeBadge
type={client.userType ?? "oidc"}
name={
client.idpName?.trim()
? client.idpName
: t("idpNameInternal")
}
variant={
client.idpVariant ?? undefined
}
/>
)}
</div>
</InfoSectionContent>
</InfoSection>
<InfoSection>

View File

@@ -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 <span>-</span>;
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {

View File

@@ -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 ? (
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
<div className="flex items-center gap-2">
<Link
href={`/${r.orgId}/settings/access/users/${r.userId}`}
>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
username: r.username
}) || r.userId}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
{(r.userType ?? "internal") !== "internal" && (
<IdpTypeBadge
type={r.userType ?? "oidc"}
name={
r.idpName?.trim()
? r.idpName
: t("idpNameInternal")
}
variant={r.idpVariant ?? undefined}
/>
)}
</div>
) : (
"-"
);