mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-06 12:27:39 +00:00
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user