Merge branch 'dev' of github.com:fosrl/pangolin into dev

This commit is contained in:
Owen
2026-02-15 11:09:11 -08:00
64 changed files with 4463 additions and 1411 deletions

View File

@@ -10,7 +10,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"[typescriptreact]": { "[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"

View File

@@ -201,6 +201,7 @@
"protocolSelect": "Select a protocol", "protocolSelect": "Select a protocol",
"resourcePortNumber": "Port Number", "resourcePortNumber": "Port Number",
"resourcePortNumberDescription": "The external port number to proxy requests.", "resourcePortNumberDescription": "The external port number to proxy requests.",
"back": "Back",
"cancel": "Cancel", "cancel": "Cancel",
"resourceConfig": "Configuration Snippets", "resourceConfig": "Configuration Snippets",
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
@@ -246,6 +247,17 @@
"orgErrorDeleteMessage": "An error occurred while deleting the organization.", "orgErrorDeleteMessage": "An error occurred while deleting the organization.",
"orgDeleted": "Organization deleted", "orgDeleted": "Organization deleted",
"orgDeletedMessage": "The organization and its data has been deleted.", "orgDeletedMessage": "The organization and its data has been deleted.",
"deleteAccount": "Delete Account",
"deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.",
"deleteAccountButton": "Delete Account",
"deleteAccountConfirmTitle": "Delete Account",
"deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.",
"deleteAccountConfirmString": "delete account",
"deleteAccountSuccess": "Account Deleted",
"deleteAccountSuccessMessage": "Your account has been deleted.",
"deleteAccountError": "Failed to delete account",
"deleteAccountPreviewAccount": "Your Account",
"deleteAccountPreviewOrgs": "Organizations you own (and all their data)",
"orgMissing": "Organization ID Missing", "orgMissing": "Organization ID Missing",
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
"accessUsersManage": "Manage Users", "accessUsersManage": "Manage Users",
@@ -461,6 +473,8 @@
"filterByApprovalState": "Filter By Approval State", "filterByApprovalState": "Filter By Approval State",
"approvalListEmpty": "No approvals", "approvalListEmpty": "No approvals",
"approvalState": "Approval State", "approvalState": "Approval State",
"approvalLoadMore": "Load more",
"loadingApprovals": "Loading Approvals",
"approve": "Approve", "approve": "Approve",
"approved": "Approved", "approved": "Approved",
"denied": "Denied", "denied": "Denied",
@@ -1169,7 +1183,8 @@
"actionViewLogs": "View Logs", "actionViewLogs": "View Logs",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"searchProgress": "Search...", "searchPlaceholder": "Search...",
"emptySearchOptions": "No options found",
"create": "Create", "create": "Create",
"orgs": "Organizations", "orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.", "loginError": "An unexpected error occurred. Please try again.",
@@ -1916,6 +1931,9 @@
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?", "authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
"authPageBrandingDeleteConfirm": "Confirm Delete Branding", "authPageBrandingDeleteConfirm": "Confirm Delete Branding",
"brandingLogoURL": "Logo URL", "brandingLogoURL": "Logo URL",
"brandingLogoURLOrPath": "Logo URL or Path",
"brandingLogoPathDescription": "Enter a URL or a local path.",
"brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.",
"brandingPrimaryColor": "Primary Color", "brandingPrimaryColor": "Primary Color",
"brandingLogoWidth": "Width (px)", "brandingLogoWidth": "Width (px)",
"brandingLogoHeight": "Height (px)", "brandingLogoHeight": "Height (px)",

13
package-lock.json generated
View File

@@ -94,6 +94,7 @@
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "^10.1.0",
"uuid": "13.0.0", "uuid": "13.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",
@@ -21248,6 +21249,18 @@
} }
} }
}, },
"node_modules/use-debounce": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
"integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-intl": { "node_modules/use-intl": {
"version": "4.8.2", "version": "4.8.2",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz",

View File

@@ -117,6 +117,7 @@
"tailwind-merge": "3.4.0", "tailwind-merge": "3.4.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"use-debounce": "^10.1.0",
"uuid": "13.0.0", "uuid": "13.0.0",
"vaul": "1.1.2", "vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0", "visionscarto-world-atlas": "1.0.0",

View File

@@ -1,18 +1,16 @@
import {
pgTable,
serial,
varchar,
boolean,
integer,
bigint,
real,
text,
index,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { alias } from "yargs"; import { InferSelectModel } from "drizzle-orm";
import {
bigint,
boolean,
index,
integer,
pgTable,
real,
serial,
text,
varchar
} from "drizzle-orm/pg-core";
export const domains = pgTable("domains", { export const domains = pgTable("domains", {
domainId: varchar("domainId").primaryKey(), domainId: varchar("domainId").primaryKey(),
@@ -188,7 +186,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcFollowRedirects: boolean("hcFollowRedirects").default(true),
hcMethod: varchar("hcMethod").default("GET"), hcMethod: varchar("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName") hcTlsServerName: text("hcTlsServerName")
}); });
@@ -218,7 +218,7 @@ export const siteResources = pgTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
mode: varchar("mode").notNull(), // "host" | "cidr" | "port" mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
protocol: varchar("protocol"), // only for port mode protocol: varchar("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode

View File

@@ -1,13 +1,6 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { InferSelectModel } from "drizzle-orm"; import { InferSelectModel } from "drizzle-orm";
import { import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
sqliteTable,
text,
integer,
index,
uniqueIndex
} from "drizzle-orm/sqlite-core";
import { no } from "zod/v4/locales";
export const domains = sqliteTable("domains", { export const domains = sqliteTable("domains", {
domainId: text("domainId").primaryKey(), domainId: text("domainId").primaryKey(),
@@ -214,7 +207,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
}).default(true), }).default(true),
hcMethod: text("hcMethod").default("GET"), hcMethod: text("hcMethod").default("GET"),
hcStatus: integer("hcStatus"), // http code hcStatus: integer("hcStatus"), // http code
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" hcHealth: text("hcHealth")
.$type<"unknown" | "healthy" | "unhealthy">()
.default("unknown"), // "unknown", "healthy", "unhealthy"
hcTlsServerName: text("hcTlsServerName") hcTlsServerName: text("hcTlsServerName")
}); });
@@ -246,7 +241,7 @@ export const siteResources = sqliteTable("siteResources", {
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
mode: text("mode").notNull(), // "host" | "cidr" | "port" mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
protocol: text("protocol"), // only for port mode protocol: text("protocol"), // only for port mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode

169
server/lib/deleteOrg.ts Normal file
View File

@@ -0,0 +1,169 @@
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
orgs,
resources,
sites
} from "@server/db";
import { newts, newtSessions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
olmsToTerminate: string[];
};
/**
* Deletes one organization and its related data. Returns ids for termination
* messages; caller should call sendTerminationMessages with the result.
* Throws if org not found.
*/
export async function deleteOrgById(
orgId: string
): Promise<DeleteOrgByIdResult> {
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
);
}
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(clientSitesAssociationsCache.clientId, client.clientId)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
return { deletedNewtIds, olmsToTerminate };
}
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
for (const newtId of result.deletedNewtIds) {
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
(error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
}
);
}
for (const olmId of result.olmsToTerminate) {
sendTerminateClient(
0,
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
}

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import type { Request, Response, NextFunction } from "express"; import type { Request, Response, NextFunction } from "express";
import { approvals, db, type Approval } from "@server/db"; import { approvals, db, type Approval } from "@server/db";
import { eq, sql, and } from "drizzle-orm"; import { eq, sql, and, inArray } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -88,7 +88,7 @@ export async function countApprovals(
.where( .where(
and( and(
eq(approvals.orgId, orgId), eq(approvals.orgId, orgId),
sql`${approvals.decision} in ${state}` inArray(approvals.decision, state)
) )
); );

View File

@@ -28,7 +28,7 @@ import {
currentFingerprint, currentFingerprint,
type Approval type Approval
} from "@server/db"; } from "@server/db";
import { eq, isNull, sql, not, and, desc } from "drizzle-orm"; import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
}); });
const querySchema = z.strictObject({ const querySchema = z.strictObject({
limit: z limit: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20),
.pipe(z.int().nonnegative()), cursorPending: z.coerce // pending cursor
offset: z .number<string>()
.string() .int()
.max(1) // 0 means non pending
.min(0) // 1 means pending
.optional() .optional()
.default("0") .catch(undefined),
.transform(Number) cursorTimestamp: z.coerce
.pipe(z.int().nonnegative()), .number<string>()
.int()
.positive()
.optional()
.catch(undefined),
approvalState: z approvalState: z
.enum(["pending", "approved", "denied", "all"]) .enum(["pending", "approved", "denied", "all"])
.optional() .optional()
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
.pipe(z.number().int().positive().optional()) .pipe(z.number().int().positive().optional())
}); });
async function queryApprovals( async function queryApprovals({
orgId: string, orgId,
limit: number, limit,
offset: number, approvalState,
approvalState: z.infer<typeof querySchema>["approvalState"], cursorPending,
clientId?: number cursorTimestamp,
) { clientId
}: {
orgId: string;
limit: number;
approvalState: z.infer<typeof querySchema>["approvalState"];
cursorPending?: number;
cursorTimestamp?: number;
clientId?: number;
}) {
let state: Array<Approval["decision"]> = []; let state: Array<Approval["decision"]> = [];
switch (approvalState) { switch (approvalState) {
case "pending": case "pending":
@@ -83,6 +99,26 @@ async function queryApprovals(
state = ["approved", "denied", "pending"]; state = ["approved", "denied", "pending"];
} }
const conditions = [
eq(approvals.orgId, orgId),
sql`${approvals.decision} in ${state}`
];
if (clientId) {
conditions.push(eq(approvals.clientId, clientId));
}
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
if (cursorPending != null && cursorTimestamp != null) {
// https://stackoverflow.com/a/79720298/10322846
// composite cursor, next data means (pending, timestamp) <= cursor
conditions.push(
lte(pendingSortKey, cursorPending),
lte(approvals.timestamp, cursorTimestamp)
);
}
const res = await db const res = await db
.select({ .select({
approvalId: approvals.approvalId, approvalId: approvals.approvalId,
@@ -105,7 +141,8 @@ async function queryApprovals(
fingerprintArch: currentFingerprint.arch, fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username, fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname fingerprintHostname: currentFingerprint.hostname,
timestamp: approvals.timestamp
}) })
.from(approvals) .from(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId))) .innerJoin(users, and(eq(approvals.userId, users.userId)))
@@ -118,22 +155,12 @@ async function queryApprovals(
) )
.leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
.where( .where(and(...conditions))
and( .orderBy(desc(pendingSortKey), desc(approvals.timestamp))
eq(approvals.orgId, orgId), .limit(limit + 1); // the `+1` is used for the cursor
sql`${approvals.decision} in ${state}`,
...(clientId ? [eq(approvals.clientId, clientId)] : [])
)
)
.orderBy(
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
desc(approvals.timestamp)
)
.limit(limit)
.offset(offset);
// Process results to format device names and build fingerprint objects // Process results to format device names and build fingerprint objects
return res.map((approval) => { const approvalsList = res.slice(0, limit).map((approval) => {
const model = approval.deviceModel || null; const model = approval.deviceModel || null;
const deviceName = approval.clientName const deviceName = approval.clientName
? getUserDeviceName(model, approval.clientName) ? getUserDeviceName(model, approval.clientName)
@@ -152,15 +179,15 @@ async function queryApprovals(
const fingerprint = hasFingerprintData const fingerprint = hasFingerprintData
? { ? {
platform: approval.fingerprintPlatform || null, platform: approval.fingerprintPlatform ?? null,
osVersion: approval.fingerprintOsVersion || null, osVersion: approval.fingerprintOsVersion ?? null,
kernelVersion: approval.fingerprintKernelVersion || null, kernelVersion: approval.fingerprintKernelVersion ?? null,
arch: approval.fingerprintArch || null, arch: approval.fingerprintArch ?? null,
deviceModel: approval.deviceModel || null, deviceModel: approval.deviceModel ?? null,
serialNumber: approval.fingerprintSerialNumber || null, serialNumber: approval.fingerprintSerialNumber ?? null,
username: approval.fingerprintUsername || null, username: approval.fingerprintUsername ?? null,
hostname: approval.fingerprintHostname || null hostname: approval.fingerprintHostname ?? null
} }
: null; : null;
const { const {
@@ -183,11 +210,30 @@ async function queryApprovals(
niceId: approval.niceId || null niceId: approval.niceId || null
}; };
}); });
let nextCursorPending: number | null = null;
let nextCursorTimestamp: number | null = null;
if (res.length > limit) {
const lastItem = res[limit];
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
nextCursorTimestamp = lastItem.timestamp;
}
return {
approvalsList,
nextCursorPending,
nextCursorTimestamp
};
} }
export type ListApprovalsResponse = { export type ListApprovalsResponse = {
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>; approvals: NonNullable<
pagination: { total: number; limit: number; offset: number }; Awaited<ReturnType<typeof queryApprovals>>
>["approvalsList"];
pagination: {
total: number;
limit: number;
cursorPending: number | null;
cursorTimestamp: number | null;
};
}; };
export async function listApprovals( export async function listApprovals(
@@ -215,17 +261,25 @@ export async function listApprovals(
) )
); );
} }
const { limit, offset, approvalState, clientId } = parsedQuery.data; const {
limit,
cursorPending,
cursorTimestamp,
approvalState,
clientId
} = parsedQuery.data;
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const approvalsList = await queryApprovals( const { approvalsList, nextCursorPending, nextCursorTimestamp } =
orgId.toString(), await queryApprovals({
limit, orgId: orgId.toString(),
offset, limit,
approvalState, cursorPending,
clientId cursorTimestamp,
); approvalState,
clientId
});
const [{ count }] = await db const [{ count }] = await db
.select({ count: sql<number>`count(*)` }) .select({ count: sql<number>`count(*)` })
@@ -237,7 +291,8 @@ export async function listApprovals(
pagination: { pagination: {
total: count, total: count,
limit, limit,
offset cursorPending: nextCursorPending,
cursorTimestamp: nextCursorTimestamp
} }
}, },
success: true, success: true,

View File

@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); req.params
);
if (!parsedParams.success) { if (!parsedParams.success) {
return next( return next(
createHttpError( createHttpError(
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
const licenseData = req.body; const licenseData = req.body;
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { if (
licenseData.tier != "big_license" &&
licenseData.tier != "small_license"
) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
return next( return next(
createHttpError( createHttpError(
apiResponse.status || HttpCode.BAD_REQUEST, apiResponse.status || HttpCode.BAD_REQUEST,
apiResponse.message || "Failed to create license from Fossorial API" apiResponse.message ||
"Failed to create license from Fossorial API"
) )
); );
} }
@@ -112,7 +117,10 @@ export async function generateNewEnterpriseLicense(
); );
} }
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; const tier =
licenseData.tier === "big_license"
? LicenseId.BIG_LICENSE
: LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]; const tierPrice = getLicensePriceSet()[tier];
const session = await stripe!.checkout.sessions.create({ const session = await stripe!.checkout.sessions.create({
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
{ {
price: tierPrice, // Use the standard tier price: tierPrice, // Use the standard tier
quantity: 1 quantity: 1
}, }
], // Start with the standard feature set that matches the free limits ], // Start with the standard feature set that matches the free limits
customer: customer.customerId, customer: customer.customerId,
mode: "subscription", mode: "subscription",

View File

@@ -26,6 +26,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq, InferInsertModel } from "drizzle-orm"; import { eq, InferInsertModel } from "drizzle-orm";
import { build } from "@server/build"; import { build } from "@server/build";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import config from "#private/lib/config"; import config from "#private/lib/config";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
@@ -37,14 +38,36 @@ const bodySchema = z.strictObject({
.union([ .union([
z.literal(""), z.literal(""),
z z
.url("Must be a valid URL") .string()
.superRefine(async (url, ctx) => { .superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try { try {
const response = await fetch(url, { const response = await fetch(urlOrPath, {
method: "HEAD" method: "HEAD"
}).catch(() => { }).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET // If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" }); return fetch(urlOrPath, { method: "GET" });
}); });
if (response.status !== 200) { if (response.status !== 200) {

View File

@@ -0,0 +1,228 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, orgs, userOrgs, users } from "@server/db";
import { eq, and, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { verifySession } from "@server/auth/sessions/verifySession";
import {
invalidateSession,
createBlankSessionTokenCookie
} from "@server/auth/sessions/app";
import { verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp";
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
import {
deleteOrgById,
sendTerminationMessages
} from "@server/lib/deleteOrg";
import { UserType } from "@server/types/UserTypes";
const deleteMyAccountBody = z.strictObject({
password: z.string().optional(),
code: z.string().optional()
});
export type DeleteMyAccountPreviewResponse = {
preview: true;
orgs: { orgId: string; name: string }[];
twoFactorEnabled: boolean;
};
export type DeleteMyAccountCodeRequestedResponse = {
codeRequested: true;
};
export type DeleteMyAccountSuccessResponse = {
success: true;
};
/**
* Self-service account deletion (saas only). Returns preview when no password;
* requires password and optional 2FA code to perform deletion. Uses shared
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
*/
export async function deleteMyAccount(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const { user, session } = await verifySession(req);
if (!user || !session) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
);
}
if (user.serverAdmin) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admins cannot delete their account this way"
)
);
}
if (user.type !== UserType.Internal) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Account deletion with password is only supported for internal users"
)
);
}
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { password, code } = parsed.data;
const userId = user.userId;
const ownedOrgsRows = await db
.select({
orgId: userOrgs.orgId
})
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.isOwner, true)
)
);
const orgIds = ownedOrgsRows.map((r) => r.orgId);
if (!password) {
const orgsWithNames =
orgIds.length > 0
? await db
.select({
orgId: orgs.orgId,
name: orgs.name
})
.from(orgs)
.where(inArray(orgs.orgId, orgIds))
: [];
return response<DeleteMyAccountPreviewResponse>(res, {
data: {
preview: true,
orgs: orgsWithNames.map((o) => ({
orgId: o.orgId,
name: o.name ?? ""
})),
twoFactorEnabled: user.twoFactorEnabled ?? false
},
success: true,
error: false,
message: "Preview",
status: HttpCode.OK
});
}
const validPassword = await verifyPassword(
password,
user.passwordHash!
);
if (!validPassword) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
);
}
if (user.twoFactorEnabled) {
if (!code) {
return response<DeleteMyAccountCodeRequestedResponse>(res, {
data: { codeRequested: true },
success: true,
error: false,
message: "Two-factor code required",
status: HttpCode.ACCEPTED
});
}
const validOTP = await verifyTotpCode(
code,
user.twoFactorSecret!,
user.userId
);
if (!validOTP) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The two-factor code you entered is incorrect"
)
);
}
}
const allDeletedNewtIds: string[] = [];
const allOlmsToTerminate: string[] = [];
for (const row of ownedOrgsRows) {
try {
const result = await deleteOrgById(row.orgId);
allDeletedNewtIds.push(...result.deletedNewtIds);
allOlmsToTerminate.push(...result.olmsToTerminate);
} catch (err) {
logger.error(
`Failed to delete org ${row.orgId} during account deletion`,
err
);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete organization"
)
);
}
}
sendTerminationMessages({
deletedNewtIds: allDeletedNewtIds,
olmsToTerminate: allOlmsToTerminate
});
await db.transaction(async (trx) => {
await trx.delete(users).where(eq(users.userId, userId));
await calculateUserClientsForOrgs(userId, trx);
});
try {
await invalidateSession(session.sessionId);
} catch (error) {
logger.error(
"Failed to invalidate session after account deletion",
error
);
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<DeleteMyAccountSuccessResponse>(res, {
data: { success: true },
success: true,
error: false,
message: "Account deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred"
)
);
}
}

View File

@@ -18,3 +18,4 @@ export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth"; export * from "./verifyDeviceWebAuth";
export * from "./pollDeviceWebAuth"; export * from "./pollDeviceWebAuth";
export * from "./lookupUser"; export * from "./lookupUser";
export * from "./deleteMyAccount";

View File

@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
export * from "./blockClient"; export * from "./blockClient";
export * from "./unblockClient"; export * from "./unblockClient";
export * from "./listClients"; export * from "./listClients";
export * from "./listUserDevices";
export * from "./updateClient"; export * from "./updateClient";
export * from "./getClient"; export * from "./getClient";
export * from "./createUserClient"; export * from "./createUserClient";

View File

@@ -1,34 +1,38 @@
import { db, olms, users } from "@server/db";
import { import {
clients, clients,
clientSitesAssociationsCache,
currentFingerprint,
db,
olms,
orgs, orgs,
roleClients, roleClients,
sites, sites,
userClients, userClients,
clientSitesAssociationsCache, users
currentFingerprint
} from "@server/db"; } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { import {
and, and,
count, asc,
desc,
eq, eq,
inArray, inArray,
isNotNull,
isNull, isNull,
like,
or, or,
sql sql,
type SQL
} from "drizzle-orm"; } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import NodeCache from "node-cache"; import NodeCache from "node-cache";
import semver from "semver"; import semver from "semver";
import { getUserDeviceName } from "@server/db/names"; import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 }); const olmVersionCache = new NodeCache({ stdTTL: 3600 });
@@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({
}); });
const listClientsSchema = z.object({ const listClientsSchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20)
.pipe(z.int().positive()), .openapi({
offset: z type: "integer",
.string() default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1)
.pipe(z.int().nonnegative()), .openapi({
filter: z.enum(["user", "machine"]).optional() type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
online: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter by online status"
}),
status: z.preprocess(
(val: string | undefined) => {
if (val) {
return val.split(","); // the search query array is an array joined by commas
}
return undefined;
},
z
.array(z.enum(["active", "blocked", "archived"]))
.optional()
.default(["active"])
.catch(["active"])
.openapi({
type: "array",
items: {
type: "string",
enum: ["active", "blocked", "archived"]
},
default: ["active"],
description:
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
})
)
}); });
function queryClients( function queryClientsBase() {
orgId: string,
accessibleClientIds: number[],
filter?: "user" | "machine"
) {
const conditions = [
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId)
];
// Add filter condition based on filter type
if (filter === "user") {
conditions.push(isNotNull(clients.userId));
} else if (filter === "machine") {
conditions.push(isNull(clients.userId));
}
return db return db
.select({ .select({
clientId: clients.clientId, clientId: clients.clientId,
@@ -142,22 +194,13 @@ function queryClients(
approvalState: clients.approvalState, approvalState: clients.approvalState,
olmArchived: olms.archived, olmArchived: olms.archived,
archived: clients.archived, archived: clients.archived,
blocked: clients.blocked, blocked: clients.blocked
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
}) })
.from(clients) .from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId)) .leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
.where(and(...conditions));
} }
async function getSiteAssociations(clientIds: number[]) { async function getSiteAssociations(clientIds: number[]) {
@@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) {
.where(inArray(clientSitesAssociationsCache.clientId, clientIds)); .where(inArray(clientSitesAssociationsCache.clientId, clientIds));
} }
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & { type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
sites: Array<{ sites: Array<{
siteId: number; siteId: number;
siteName: string | null; siteName: string | null;
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
type OlmWithUpdateAvailable = ClientWithSites; type OlmWithUpdateAvailable = ClientWithSites;
export type ListClientsResponse = { export type ListClientsResponse = PaginatedResponse<{
clients: Array<ClientWithSites>; clients: Array<ClientWithSites>;
pagination: { total: number; limit: number; offset: number }; }>;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -218,7 +260,8 @@ export async function listClients(
) )
); );
} }
const { limit, offset, filter } = parsedQuery.data; const { page, pageSize, online, query, status, sort_by, order } =
parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params); const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -267,28 +310,73 @@ export async function listClients(
const accessibleClientIds = accessibleClients.map( const accessibleClientIds = accessibleClients.map(
(client) => client.clientId (client) => client.clientId
); );
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
// Get client count with filter // Get client count with filter
const countConditions = [ const conditions = [
inArray(clients.clientId, accessibleClientIds), and(
eq(clients.orgId, orgId) inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId),
isNull(clients.userId)
)
]; ];
if (filter === "user") { if (typeof online !== "undefined") {
countConditions.push(isNotNull(clients.userId)); conditions.push(eq(clients.online, online));
} else if (filter === "machine") {
countConditions.push(isNull(clients.userId));
} }
const countQuery = db if (status.length > 0) {
.select({ count: count() }) const filterAggregates: (SQL<unknown> | undefined)[] = [];
.from(clients)
.where(and(...countConditions));
const clientsList = await baseQuery.limit(limit).offset(offset); if (status.includes("active")) {
const totalCountResult = await countQuery; filterAggregates.push(
const totalCount = totalCountResult[0].count; and(eq(clients.archived, false), eq(clients.blocked, false))
);
}
if (status.includes("archived")) {
filterAggregates.push(eq(clients.archived, true));
}
if (status.includes("blocked")) {
filterAggregates.push(eq(clients.blocked, true));
}
conditions.push(or(...filterAggregates));
}
if (query) {
conditions.push(
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
const baseQuery = queryClientsBase().where(and(...conditions));
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listMachinesQuery = baseQuery
.limit(page)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
: asc(clients.clientId)
);
const [clientsList, totalCount] = await Promise.all([
listMachinesQuery,
countQuery
]);
// Get associated sites for all clients // Get associated sites for all clients
const clientIds = clientsList.map((client) => client.clientId); const clientIds = clientsList.map((client) => client.clientId);
@@ -319,14 +407,8 @@ export async function listClients(
// Merge clients with their site associations and replace name with device name // Merge clients with their site associations and replace name with device name
const clientsWithSites = clientsList.map((client) => { const clientsWithSites = clientsList.map((client) => {
const model = client.deviceModel || null;
let newName = client.name;
if (filter === "user") {
newName = getUserDeviceName(model, client.name);
}
return { return {
...client, ...client,
name: newName,
sites: sitesByClient[client.clientId] || [] sites: sitesByClient[client.clientId] || []
}; };
}); });
@@ -371,8 +453,8 @@ export async function listClients(
clients: olmsWithUpdates, clients: olmsWithUpdates,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, page,
offset pageSize
} }
}, },
success: true, success: true,

View File

@@ -0,0 +1,500 @@
import { build } from "@server/build";
import {
clients,
currentFingerprint,
db,
olms,
orgs,
roleClients,
userClients,
users
} from "@server/db";
import { getUserDeviceName } from "@server/db/names";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
and,
asc,
desc,
eq,
inArray,
isNotNull,
isNull,
like,
or,
sql,
type SQL
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserDevicesSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
online: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter by online status"
}),
agent: z
.enum([
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: [
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
],
description:
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
}),
status: z.preprocess(
(val: string | undefined) => {
if (val) {
return val.split(","); // the search query array is an array joined by commas
}
return undefined;
},
z
.array(
z.enum(["active", "pending", "denied", "blocked", "archived"])
)
.optional()
.default(["active", "pending"])
.catch(["active", "pending"])
.openapi({
type: "array",
items: {
type: "string",
enum: ["active", "pending", "denied", "blocked", "archived"]
},
default: ["active", "pending"],
description:
"Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled."
})
)
});
function queryUserDevicesBase() {
return db
.select({
clientId: clients.clientId,
orgId: clients.orgId,
name: clients.name,
pubKey: clients.pubKey,
subnet: clients.subnet,
megabytesIn: clients.megabytesIn,
megabytesOut: clients.megabytesOut,
orgName: orgs.name,
type: clients.type,
online: clients.online,
olmVersion: olms.version,
userId: clients.userId,
username: users.username,
userEmail: users.email,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}
type OlmWithUpdateAvailable = Awaited<
ReturnType<typeof queryUserDevicesBase>
>[0] & {
olmUpdateAvailable?: boolean;
};
export type ListUserDevicesResponse = PaginatedResponse<{
devices: Array<OlmWithUpdateAvailable>;
}>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/user-devices",
description: "List all user devices for an organization.",
tags: [OpenAPITags.Client, OpenAPITags.Org],
request: {
query: listUserDevicesSchema,
params: listUserDevicesParamsSchema
},
responses: {}
});
export async function listUserDevices(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { page, pageSize, query, sort_by, online, status, agent, order } =
parsedQuery.data;
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
let accessibleClients;
if (req.user) {
accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
.from(userClients)
.fullJoin(
roleClients,
eq(userClients.clientId, roleClients.clientId)
)
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleClients = await db
.select({ clientId: clients.clientId })
.from(clients)
.where(eq(clients.orgId, orgId));
}
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId
);
// Get client count with filter
const conditions = [
and(
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId),
isNotNull(clients.userId)
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${users.email})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") {
conditions.push(eq(clients.online, online));
}
const agentValueMap = {
windows: "Pangolin Windows",
android: "Pangolin Android",
ios: "Pangolin iOS",
ipados: "Pangolin iPadOS",
macos: "Pangolin macOS",
cli: "Pangolin CLI",
olm: "Olm CLI"
} satisfies Record<
Exclude<typeof agent, undefined | "unknown">,
string
>;
if (typeof agent !== "undefined") {
if (agent === "unknown") {
conditions.push(isNull(olms.agent));
} else {
conditions.push(eq(olms.agent, agentValueMap[agent]));
}
}
if (status.length > 0) {
const filterAggregates: (SQL<unknown> | undefined)[] = [];
if (status.includes("active")) {
filterAggregates.push(
and(
eq(clients.archived, false),
eq(clients.blocked, false),
build !== "oss"
? or(
eq(clients.approvalState, "approved"),
isNull(clients.approvalState) // approval state of `NULL` means approved by default
)
: undefined // undefined are automatically ignored by `drizzle-orm`
)
);
}
if (status.includes("archived")) {
filterAggregates.push(eq(clients.archived, true));
}
if (status.includes("blocked")) {
filterAggregates.push(eq(clients.blocked, true));
}
if (build !== "oss") {
if (status.includes("pending")) {
filterAggregates.push(eq(clients.approvalState, "pending"));
}
if (status.includes("denied")) {
filterAggregates.push(eq(clients.approvalState, "denied"));
}
}
conditions.push(or(...filterAggregates));
}
const baseQuery = queryUserDevicesBase().where(and(...conditions));
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listDevicesQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
: asc(clients.clientId)
);
const [clientsList, totalCount] = await Promise.all([
listDevicesQuery,
countQuery
]);
// Merge clients with their site associations and replace name with device name
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
(client) => {
const model = client.deviceModel || null;
const newName = getUserDeviceName(model, client.name);
const OlmWithUpdate: OlmWithUpdateAvailable = {
...client,
name: newName
};
// Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate;
}
);
// Try to get the latest version, but don't block if it fails
try {
const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlmVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
return response<ListUserDevicesResponse>(res, {
data: {
devices: olmsWithUpdates,
pagination: {
total: totalCount,
page,
pageSize
}
},
success: true,
error: false,
message: "Clients retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -145,6 +145,13 @@ authenticated.get(
client.listClients client.listClients
); );
authenticated.get(
"/org/:orgId/user-devices",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listClients),
client.listUserDevices
);
authenticated.get( authenticated.get(
"/client/:clientId", "/client/:clientId",
verifyClientAccess, verifyClientAccess,
@@ -1164,6 +1171,7 @@ authRouter.post(
auth.login auth.login
); );
authRouter.post("/logout", auth.logout); authRouter.post("/logout", auth.logout);
authRouter.post("/delete-my-account", auth.deleteMyAccount);
authRouter.post( authRouter.post(
"/lookup-user", "/lookup-user",
rateLimit({ rateLimit({

View File

@@ -70,6 +70,15 @@ export async function createIdpOrgPolicy(
const { idpId, orgId } = parsedParams.data; const { idpId, orgId } = parsedParams.data;
const { roleMapping, orgMapping } = parsedBody.data; const { roleMapping, orgMapping } = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
)
);
}
const [existing] = await db const [existing] = await db
.select() .select()
.from(idp) .from(idp)

View File

@@ -80,6 +80,17 @@ export async function createOidcIdp(
tags tags
} = parsedBody.data; } = parsedBody.data;
if (
process.env.IDENTITY_PROVIDER_MODE === "org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
)
);
}
const key = config.getRawConfig().server.secret!; const key = config.getRawConfig().server.secret!;
const encryptedSecret = encrypt(clientSecret, key); const encryptedSecret = encrypt(clientSecret, key);

View File

@@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy(
const { idpId, orgId } = parsedParams.data; const { idpId, orgId } = parsedParams.data;
const { roleMapping, orgMapping } = parsedBody.data; const { roleMapping, orgMapping } = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
)
);
}
// Check if IDP and policy exist // Check if IDP and policy exist
const [existing] = await db const [existing] = await db
.select() .select()

View File

@@ -99,6 +99,15 @@ export async function updateOidcIdp(
tags tags
} = parsedBody.data; } = parsedBody.data;
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
)
);
}
// Check if IDP exists and is of type OIDC // Check if IDP exists and is of type OIDC
const [existingIdp] = await db const [existingIdp] = await db
.select() .select()

View File

@@ -866,6 +866,13 @@ authenticated.get(
client.listClients client.listClients
); );
authenticated.get(
"/org/:orgId/user-devices",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listClients),
client.listUserDevices
);
authenticated.get( authenticated.get(
"/client/:clientId", "/client/:clientId",
verifyApiKeyClientAccess, verifyApiKeyClientAccess,

View File

@@ -1,28 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import {
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
domains,
olms,
orgDomains,
resources
} from "@server/db";
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
import { eq, and, inArray, sql } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { OlmErrorCodes } from "../olm/error"; import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
import { sendTerminateClient } from "../client/terminate";
const deleteOrgSchema = z.strictObject({ const deleteOrgSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -56,170 +40,9 @@ export async function deleteOrg(
) )
); );
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const result = await deleteOrgById(orgId);
const [org] = await db sendTerminationMessages(result);
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
// we need to handle deleting each site
const orgSites = await db
.select()
.from(sites)
.where(eq(sites.orgId, orgId))
.limit(1);
const orgClients = await db
.select()
.from(clients)
.where(eq(clients.orgId, orgId));
const deletedNewtIds: string[] = [];
const olmsToTerminate: string[] = [];
await db.transaction(async (trx) => {
for (const site of orgSites) {
if (site.pubKey) {
if (site.type == "wireguard") {
await deletePeer(site.exitNodeId!, site.pubKey);
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [deletedNewt] = await trx
.delete(newts)
.where(eq(newts.siteId, site.siteId))
.returning();
if (deletedNewt) {
deletedNewtIds.push(deletedNewt.newtId);
// delete all of the sessions for the newt
await trx
.delete(newtSessions)
.where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
}
}
}
logger.info(`Deleting site ${site.siteId}`);
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
}
for (const client of orgClients) {
const [olm] = await trx
.select()
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (olm) {
olmsToTerminate.push(olm.olmId);
}
logger.info(`Deleting client ${client.clientId}`);
await trx
.delete(clients)
.where(eq(clients.clientId, client.clientId));
// also delete the associations
await trx
.delete(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
);
await trx
.delete(clientSitesAssociationsCache)
.where(
eq(
clientSitesAssociationsCache.clientId,
client.clientId
)
);
}
const allOrgDomains = await trx
.select()
.from(orgDomains)
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where(
and(
eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false)
)
);
// For each domain, check if it belongs to multiple organizations
const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId;
// Count how many organizations this domain belongs to
const orgCount = await trx
.select({ count: sql<number>`count(*)` })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
if (orgCount[0].count === 1) {
domainIdsToDelete.push(domainId);
}
}
// Delete domains that belong exclusively to this organization
if (domainIdsToDelete.length > 0) {
await trx
.delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete));
}
// Delete resources
await trx.delete(resources).where(eq(resources.orgId, orgId));
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
});
// Send termination messages outside of transaction to prevent blocking
for (const newtId of deletedNewtIds) {
const payload = {
type: `newt/wg/terminate`,
data: {}
};
// Don't await this to prevent blocking the response
sendToClient(newtId, payload).catch((error) => {
logger.error(
"Failed to send termination message to newt:",
error
);
});
}
for (const olmId of olmsToTerminate) {
sendTerminateClient(
0, // clientId not needed since we're passing olmId
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error(
"Failed to send termination message to olm:",
error
);
});
}
return response(res, { return response(res, {
data: null, data: null,
success: true, success: true,
@@ -228,6 +51,9 @@ export async function deleteOrg(
status: HttpCode.OK status: HttpCode.OK
}); });
} catch (error) { } catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
logger.error(error); logger.error(error);
return next( return next(
createHttpError( createHttpError(

View File

@@ -1,74 +1,99 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { import {
db, db,
resourceHeaderAuth, resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility resourceHeaderAuthExtendedCompatibility,
} from "@server/db";
import {
resources,
userResources,
roleResources,
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
resources,
roleResources,
targetHealthCheck,
targets, targets,
targetHealthCheck userResources
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromZodError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
and,
asc,
count,
eq,
inArray,
isNull,
like,
not,
or,
sql,
type SQL
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
const listResourcesParamsSchema = z.strictObject({ const listResourcesParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listResourcesSchema = z.object({ const listResourcesSchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20)
.pipe(z.int().nonnegative()), .openapi({
type: "integer",
offset: z default: 20,
.string() description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1)
.pipe(z.int().nonnegative()) .openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
enabled: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter resources based on enabled status"
}),
authState: z
.enum(["protected", "not_protected", "none"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["protected", "not_protected", "none"],
description:
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
}),
healthStatus: z
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
description:
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
})
}); });
// (resource fields + a single joined target)
type JoinedRow = {
resourceId: number;
niceId: string;
name: string;
ssl: boolean;
fullDomain: string | null;
passwordId: number | null;
sso: boolean;
pincodeId: number | null;
whitelist: boolean;
http: boolean;
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId: string | null;
headerAuthId: number | null;
targetId: number | null;
targetIp: string | null;
targetPort: number | null;
targetEnabled: boolean | null;
hcHealth: string | null;
hcEnabled: boolean | null;
};
// grouped by resource with targets[]) // grouped by resource with targets[])
export type ResourceWithTargets = { export type ResourceWithTargets = {
resourceId: number; resourceId: number;
@@ -91,11 +116,32 @@ export type ResourceWithTargets = {
ip: string; ip: string;
port: number; port: number;
enabled: boolean; enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown"; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
}>; }>;
}; };
function queryResources(accessibleResourceIds: number[], orgId: string) { // Aggregate filters
const total_targets = count(targets.targetId);
const healthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
ELSE 0
END
) `;
function queryResourcesBase() {
return db return db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
@@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
niceId: resources.niceId, niceId: resources.niceId,
headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId: headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
targetId: targets.targetId,
targetIp: targets.ip,
targetPort: targets.port,
targetEnabled: targets.enabled,
hcHealth: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
}) })
.from(resources) .from(resources)
.leftJoin( .leftJoin(
@@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
targetHealthCheck, targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId) eq(targetHealthCheck.targetId, targets.targetId)
) )
.where( .groupBy(
and( resources.resourceId,
inArray(resources.resourceId, accessibleResourceIds), resourcePassword.passwordId,
eq(resources.orgId, orgId) resourcePincode.pincodeId,
) resourceHeaderAuth.headerAuthId,
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
); );
} }
export type ListResourcesResponse = { export type ListResourcesResponse = PaginatedResponse<{
resources: ResourceWithTargets[]; resources: ResourceWithTargets[];
pagination: { total: number; limit: number; offset: number }; }>;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -190,7 +229,8 @@ export async function listResources(
) )
); );
} }
const { limit, offset } = parsedQuery.data; const { page, pageSize, authState, enabled, query, healthStatus } =
parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params); const parsedParams = listResourcesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -252,14 +292,133 @@ export async function listResources(
(resource) => resource.resourceId (resource) => resource.resourceId
); );
const countQuery: any = db const conditions = [
.select({ count: count() }) and(
.from(resources) inArray(resources.resourceId, accessibleResourceIds),
.where(inArray(resources.resourceId, accessibleResourceIds)); eq(resources.orgId, orgId)
)
];
const baseQuery = queryResources(accessibleResourceIds, orgId); if (query) {
conditions.push(
or(
like(
sql`LOWER(${resources.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${resources.fullDomain})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof enabled !== "undefined") {
conditions.push(eq(resources.enabled, enabled));
}
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); if (typeof authState !== "undefined") {
switch (authState) {
case "none":
conditions.push(eq(resources.http, false));
break;
case "protected":
conditions.push(
or(
eq(resources.sso, true),
eq(resources.emailWhitelistEnabled, true),
not(isNull(resourceHeaderAuth.headerAuthId)),
not(isNull(resourcePincode.pincodeId)),
not(isNull(resourcePassword.passwordId))
)
);
break;
case "not_protected":
conditions.push(
not(eq(resources.sso, true)),
not(eq(resources.emailWhitelistEnabled, true)),
isNull(resourceHeaderAuth.headerAuthId),
isNull(resourcePincode.pincodeId),
isNull(resourcePassword.passwordId)
);
break;
}
}
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
if (typeof healthStatus !== "undefined") {
switch (healthStatus) {
case "healthy":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = ${total_targets}`
);
break;
case "degraded":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unhealthy_targets} > 0`
);
break;
case "no_targets":
aggregateFilters = sql`${total_targets} = 0`;
break;
case "offline":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = 0`,
sql`${unhealthy_targets} = ${total_targets}`
);
break;
case "unknown":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unknown_targets} = ${total_targets}`
);
break;
}
}
const baseQuery = queryResourcesBase()
.where(and(...conditions))
.having(aggregateFilters);
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_resources"));
const [rows, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(resources.resourceId)),
countQuery
]);
const resourceIdList = rows.map((row) => row.resourceId);
const allResourceTargets =
resourceIdList.length === 0
? []
: await db
.select({
targetId: targets.targetId,
resourceId: targets.resourceId,
ip: targets.ip,
port: targets.port,
enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
);
// avoids TS issues with reduce/never[] // avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>(); const map = new Map<number, ResourceWithTargets>();
@@ -288,44 +447,20 @@ export async function listResources(
map.set(row.resourceId, entry); map.set(row.resourceId, entry);
} }
if ( entry.targets = allResourceTargets.filter(
row.targetId != null && (t) => t.resourceId === entry.resourceId
row.targetIp && );
row.targetPort != null &&
row.targetEnabled != null
) {
let healthStatus: "healthy" | "unhealthy" | "unknown" =
"unknown";
if (row.hcEnabled && row.hcHealth) {
healthStatus = row.hcHealth as
| "healthy"
| "unhealthy"
| "unknown";
}
entry.targets.push({
targetId: row.targetId,
ip: row.targetIp,
port: row.targetPort,
enabled: row.targetEnabled,
healthStatus: healthStatus
});
}
} }
const resourcesList: ResourceWithTargets[] = Array.from(map.values()); const resourcesList: ResourceWithTargets[] = Array.from(map.values());
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0]?.count ?? 0;
return response<ListResourcesResponse>(res, { return response<ListResourcesResponse>(res, {
data: { data: {
resources: resourcesList, resources: resourcesList,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, pageSize,
offset page
} }
}, },
success: true, success: true,

View File

@@ -1,17 +1,25 @@
import { db, exitNodes, newts } from "@server/db"; import {
import { orgs, roleSites, sites, userSites } from "@server/db"; db,
import { remoteExitNodes } from "@server/db"; exitNodes,
import logger from "@server/logger"; newts,
import HttpCode from "@server/types/HttpCode"; orgs,
remoteExitNodes,
roleSites,
sites,
userSites
} from "@server/db";
import cache from "@server/lib/cache";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { and, count, eq, inArray, or, sql } from "drizzle-orm"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import semver from "semver";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import semver from "semver";
import cache from "@server/lib/cache";
async function getLatestNewtVersion(): Promise<string | null> { async function getLatestNewtVersion(): Promise<string | null> {
try { try {
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
}); });
const listSitesSchema = z.object({ const listSitesSchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20)
.pipe(z.int().positive()), .openapi({
offset: z type: "integer",
.string() default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1)
.pipe(z.int().nonnegative()) .openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
online: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter by online status"
})
}); });
function querySites(orgId: string, accessibleSiteIds: number[]) { function querySitesBase() {
return db return db
.select({ .select({
siteId: sites.siteId, siteId: sites.siteId,
@@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
.leftJoin( .leftJoin(
remoteExitNodes, remoteExitNodes,
eq(remoteExitNodes.exitNodeId, sites.exitNodeId) eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
)
.where(
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
); );
} }
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & { type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
newtUpdateAvailable?: boolean; newtUpdateAvailable?: boolean;
}; };
export type ListSitesResponse = { export type ListSitesResponse = PaginatedResponse<{
sites: SiteWithUpdateAvailable[]; sites: SiteWithUpdateAvailable[];
pagination: { total: number; limit: number; offset: number }; }>;
};
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -160,7 +203,6 @@ export async function listSites(
) )
); );
} }
const { limit, offset } = parsedQuery.data;
const parsedParams = listSitesParamsSchema.safeParse(req.params); const parsedParams = listSitesParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -203,34 +245,67 @@ export async function listSites(
.where(eq(sites.orgId, orgId)); .where(eq(sites.orgId, orgId));
} }
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const { pageSize, page, query, sort_by, order, online } =
const baseQuery = querySites(orgId, accessibleSiteIds); parsedQuery.data;
const countQuery = db const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
.select({ count: count() })
.from(sites) const conditions = [
.where( and(
and( inArray(sites.siteId, accessibleSiteIds),
inArray(sites.siteId, accessibleSiteIds), eq(sites.orgId, orgId)
eq(sites.orgId, orgId) )
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${sites.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${sites.niceId})`,
"%" + query.toLowerCase() + "%"
)
) )
); );
}
if (typeof online !== "undefined") {
conditions.push(eq(sites.online, online));
}
const sitesList = await baseQuery.limit(limit).offset(offset); const baseQuery = querySitesBase().where(and(...conditions));
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count; // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
querySitesBase().where(and(...conditions))
);
const siteListQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(sites[sort_by])
: desc(sites[sort_by])
: asc(sites.siteId)
);
const [totalCount, rows] = await Promise.all([
countQuery,
siteListQuery
]);
// Get latest version asynchronously without blocking the response // Get latest version asynchronously without blocking the response
const latestNewtVersionPromise = getLatestNewtVersion(); const latestNewtVersionPromise = getLatestNewtVersion();
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
(site) => { const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; // Initially set to false, will be updated if version check succeeds
// Initially set to false, will be updated if version check succeeds siteWithUpdate.newtUpdateAvailable = false;
siteWithUpdate.newtUpdateAvailable = false; return siteWithUpdate;
return siteWithUpdate; });
}
);
// Try to get the latest version, but don't block if it fails // Try to get the latest version, but don't block if it fails
try { try {
@@ -267,8 +342,8 @@ export async function listSites(
sites: sitesWithUpdates, sites: sitesWithUpdates,
pagination: { pagination: {
total: totalCount, total: totalCount,
limit, pageSize,
offset page
} }
}, },
success: true, success: true,

View File

@@ -284,7 +284,7 @@ export async function createSiteResource(
niceId, niceId,
orgId, orgId,
name, name,
mode, mode: mode as "host" | "cidr",
// protocol: mode === "port" ? protocol : null, // protocol: mode === "port" ? protocol : null,
// proxyPort: mode === "port" ? proxyPort : null, // proxyPort: mode === "port" ? proxyPort : null,
// destinationPort: mode === "port" ? destinationPort : null, // destinationPort: mode === "port" ? destinationPort : null,

View File

@@ -1,41 +1,90 @@
import { Request, Response, NextFunction } from "express"; import { db, SiteResource, siteResources, sites } from "@server/db";
import { z } from "zod";
import { db } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, eq, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
orgId: z.string() orgId: z.string()
}); });
const listAllSiteResourcesByOrgQuerySchema = z.object({ const listAllSiteResourcesByOrgQuerySchema = z.object({
limit: z pageSize: z.coerce
.string() .number<string>() // for prettier formatting
.int()
.positive()
.optional() .optional()
.default("1000") .catch(20)
.transform(Number) .default(20)
.pipe(z.int().positive()), .openapi({
offset: z type: "integer",
.string() default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional() .optional()
.default("0") .catch(1)
.transform(Number) .default(1)
.pipe(z.int().nonnegative()) .openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
mode: z
.enum(["host", "cidr"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["host", "cidr"],
description: "Filter site resources by mode"
})
}); });
export type ListAllSiteResourcesByOrgResponse = { export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & { siteResources: (SiteResource & {
siteName: string; siteName: string;
siteNiceId: string; siteNiceId: string;
siteAddress: string | null; siteAddress: string | null;
})[]; })[];
}; }>;
function querySiteResourcesBase() {
return db
.select({
siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId,
niceId: siteResources.niceId,
name: siteResources.name,
mode: siteResources.mode,
protocol: siteResources.protocol,
proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort,
destination: siteResources.destination,
enabled: siteResources.enabled,
alias: siteResources.alias,
aliasAddress: siteResources.aliasAddress,
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address
})
.from(siteResources)
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
}
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -80,39 +129,67 @@ export async function listAllSiteResourcesByOrg(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { limit, offset } = parsedQuery.data; const { page, pageSize, query, mode } = parsedQuery.data;
// Get all site resources for the org with site names const conditions = [and(eq(siteResources.orgId, orgId))];
const siteResourcesList = await db if (query) {
.select({ conditions.push(
siteResourceId: siteResources.siteResourceId, or(
siteId: siteResources.siteId, like(
orgId: siteResources.orgId, sql`LOWER(${siteResources.name})`,
niceId: siteResources.niceId, "%" + query.toLowerCase() + "%"
name: siteResources.name, ),
mode: siteResources.mode, like(
protocol: siteResources.protocol, sql`LOWER(${siteResources.niceId})`,
proxyPort: siteResources.proxyPort, "%" + query.toLowerCase() + "%"
destinationPort: siteResources.destinationPort, ),
destination: siteResources.destination, like(
enabled: siteResources.enabled, sql`LOWER(${siteResources.destination})`,
alias: siteResources.alias, "%" + query.toLowerCase() + "%"
aliasAddress: siteResources.aliasAddress, ),
tcpPortRangeString: siteResources.tcpPortRangeString, like(
udpPortRangeString: siteResources.udpPortRangeString, sql`LOWER(${siteResources.alias})`,
disableIcmp: siteResources.disableIcmp, "%" + query.toLowerCase() + "%"
siteName: sites.name, ),
siteNiceId: sites.niceId, like(
siteAddress: sites.address sql`LOWER(${siteResources.aliasAddress})`,
}) "%" + query.toLowerCase() + "%"
.from(siteResources) ),
.innerJoin(sites, eq(siteResources.siteId, sites.siteId)) like(
.where(eq(siteResources.orgId, orgId)) sql`LOWER(${sites.name})`,
.limit(limit) "%" + query.toLowerCase() + "%"
.offset(offset); )
)
);
}
return response(res, { if (mode) {
data: { siteResources: siteResourcesList }, conditions.push(eq(siteResources.mode, mode));
}
const baseQuery = querySiteResourcesBase().where(and(...conditions));
const countQuery = db.$count(
querySiteResourcesBase().where(and(...conditions))
);
const [siteResourcesList, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(siteResources.siteResourceId)),
countQuery
]);
return response<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true, success: true,
error: false, error: false,
message: "Site resources retrieved successfully", message: "Site resources retrieved successfully",

View File

@@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
await db await db
.update(targetHealthCheck) .update(targetHealthCheck)
.set({ .set({
hcHealth: healthStatus.status hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
}) })
.where(eq(targetHealthCheck.targetId, targetIdNum)) .where(eq(targetHealthCheck.targetId, targetIdNum))
.execute(); .execute();

View File

@@ -0,0 +1,5 @@
export type Pagination = { total: number; pageSize: number; page: number };
export type PaginatedResponse<T> = T & {
pagination: Pagination;
};

View File

@@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListClientsResponse } from "@server/routers/client"; import { ListClientsResponse } from "@server/routers/client";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import type { Pagination } from "@server/types/Pagination";
type ClientsPageProps = { type ClientsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations(); const t = await getTranslations();
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let machineClients: ListClientsResponse["clients"] = []; let machineClients: ListClientsResponse["clients"] = [];
let pagination: Pagination = {
page: 1,
total: 0,
pageSize: 20
};
try { try {
const machineRes = await internal.get< const machineRes = await internal.get<
AxiosResponse<ListClientsResponse> AxiosResponse<ListClientsResponse>
>( >(
`/org/${params.orgId}/clients?filter=machine`, `/org/${params.orgId}/clients?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
machineClients = machineRes.data.data.clients; const responseData = machineRes.data.data;
machineClients = responseData.clients;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
function formatSize(mb: number): string { function formatSize(mb: number): string {
@@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
<MachineClientsTable <MachineClientsTable
machineClients={machineClientRows} machineClients={machineClientRows}
orgId={params.orgId} orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/> />
</> </>
); );

View File

@@ -602,7 +602,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.biometricsEnabled .biometricsEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -622,7 +623,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.diskEncrypted .diskEncrypted ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -642,7 +644,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.firewallEnabled .firewallEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -663,7 +666,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.autoUpdatesEnabled .autoUpdatesEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -683,7 +687,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.tpmAvailable .tpmAvailable ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -707,7 +712,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.windowsAntivirusEnabled .windowsAntivirusEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -727,7 +733,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.macosSipEnabled .macosSipEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -751,7 +758,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.macosGatekeeperEnabled .macosGatekeeperEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -775,7 +783,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.macosFirewallStealthMode .macosFirewallStealthMode ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -796,7 +805,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.linuxAppArmorEnabled .linuxAppArmorEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>
@@ -817,7 +827,8 @@ export default function GeneralPage() {
) )
? formatPostureValue( ? formatPostureValue(
client.posture client.posture
.linuxSELinuxEnabled .linuxSELinuxEnabled ===
true
) )
: "-"} : "-"}
</InfoSectionContent> </InfoSectionContent>

View File

@@ -1,14 +1,16 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListClientsResponse } from "@server/routers/client";
import { getTranslations } from "next-intl/server";
import type { ClientRow } from "@app/components/UserDevicesTable"; import type { ClientRow } from "@app/components/UserDevicesTable";
import UserDevicesTable from "@app/components/UserDevicesTable"; import UserDevicesTable from "@app/components/UserDevicesTable";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { type ListUserDevicesResponse } from "@server/routers/client";
import type { Pagination } from "@server/types/Pagination";
import { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
type ClientsPageProps = { type ClientsPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) {
const t = await getTranslations(); const t = await getTranslations();
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let userClients: ListClientsResponse["clients"] = []; let userClients: ListUserDevicesResponse["devices"] = [];
let pagination: Pagination = {
page: 1,
total: 0,
pageSize: 20
};
try { try {
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>( const userRes = await internal.get<
`/org/${params.orgId}/clients?filter=user`, AxiosResponse<ListUserDevicesResponse>
>(
`/org/${params.orgId}/user-devices?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
userClients = userRes.data.data.clients; const responseData = userRes.data.data;
userClients = responseData.devices;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
function formatSize(mb: number): string { function formatSize(mb: number): string {
@@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) {
} }
const mapClientToRow = ( const mapClientToRow = (
client: ListClientsResponse["clients"][0] client: ListUserDevicesResponse["devices"][number]
): ClientRow => { ): ClientRow => {
// Build fingerprint object if any fingerprint data exists // Build fingerprint object if any fingerprint data exists
const hasFingerprintData = const hasFingerprintData =
(client as any).fingerprintPlatform || client.fingerprintPlatform ||
(client as any).fingerprintOsVersion || client.fingerprintOsVersion ||
(client as any).fingerprintKernelVersion || client.fingerprintKernelVersion ||
(client as any).fingerprintArch || client.fingerprintArch ||
(client as any).fingerprintSerialNumber || client.fingerprintSerialNumber ||
(client as any).fingerprintUsername || client.fingerprintUsername ||
(client as any).fingerprintHostname || client.fingerprintHostname ||
(client as any).deviceModel; client.deviceModel;
const fingerprint = hasFingerprintData const fingerprint = hasFingerprintData
? { ? {
platform: (client as any).fingerprintPlatform || null, platform: client.fingerprintPlatform,
osVersion: (client as any).fingerprintOsVersion || null, osVersion: client.fingerprintOsVersion,
kernelVersion: kernelVersion: client.fingerprintKernelVersion,
(client as any).fingerprintKernelVersion || null, arch: client.fingerprintArch,
arch: (client as any).fingerprintArch || null, deviceModel: client.deviceModel,
deviceModel: (client as any).deviceModel || null, serialNumber: client.fingerprintSerialNumber,
serialNumber: username: client.fingerprintUsername,
(client as any).fingerprintSerialNumber || null, hostname: client.fingerprintHostname
username: (client as any).fingerprintUsername || null,
hostname: (client as any).fingerprintHostname || null
} }
: null; : null;
@@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) {
name: client.name, name: client.name,
id: client.clientId, id: client.clientId,
subnet: client.subnet.split("/")[0], subnet: client.subnet.split("/")[0],
mbIn: formatSize(client.megabytesIn || 0), mbIn: formatSize(client.megabytesIn ?? 0),
mbOut: formatSize(client.megabytesOut || 0), mbOut: formatSize(client.megabytesOut ?? 0),
orgId: params.orgId, orgId: params.orgId,
online: client.online, online: client.online,
olmVersion: client.olmVersion || undefined, olmVersion: client.olmVersion || undefined,
olmUpdateAvailable: client.olmUpdateAvailable || false, olmUpdateAvailable: Boolean(client.olmUpdateAvailable),
userId: client.userId, userId: client.userId,
username: client.username, username: client.username,
userEmail: client.userEmail, userEmail: client.userEmail,
niceId: client.niceId, niceId: client.niceId,
agent: client.agent, agent: client.agent,
archived: client.archived || false, archived: Boolean(client.archived),
blocked: client.blocked || false, blocked: Boolean(client.blocked),
approvalState: client.approvalState, approvalState: client.approvalState,
fingerprint fingerprint
}; };
@@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
<UserDevicesTable <UserDevicesTable
userClients={userClientRows} userClients={userClientRows}
orgId={params.orgId} orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/> />
</> </>
); );

View File

@@ -14,7 +14,7 @@ import { redirect } from "next/navigation";
export interface ClientResourcesPageProps { export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
} }
export default async function ClientResourcesPage( export default async function ClientResourcesPage(
@@ -22,22 +22,24 @@ export default async function ClientResourcesPage(
) { ) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
let resources: ListResourcesResponse["resources"] = [];
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
);
resources = res.data.data.resources;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListResourcesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try { try {
const res = await internal.get< const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse> AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader()); >(
siteResources = res.data.data.siteResources; `/org/${params.orgId}/site-resources?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
siteResources = responseData.siteResources;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
let org = null; let org = null;
@@ -89,9 +91,10 @@ export default async function ClientResourcesPage(
<ClientResourcesTable <ClientResourcesTable
internalResources={internalResourceRows} internalResources={internalResourceRows}
orgId={params.orgId} orgId={params.orgId}
defaultSort={{ rowCount={pagination.total}
id: "name", pagination={{
desc: false pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}} }}
/> />
</OrgProvider> </OrgProvider>

View File

@@ -16,7 +16,7 @@ import { cache } from "react";
export interface ProxyResourcesPageProps { export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>; searchParams: Promise<Record<string, string>>;
} }
export default async function ProxyResourcesPage( export default async function ProxyResourcesPage(
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
) { ) {
const params = await props.params; const params = await props.params;
const t = await getTranslations(); const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
let resources: ListResourcesResponse["resources"] = []; let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try { try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>( const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`, `/org/${params.orgId}/resources?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
resources = res.data.data.resources; const responseData = res.data.data;
resources = responseData.resources;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
<ProxyResourcesTable <ProxyResourcesTable
resources={resourceRows} resources={resourceRows}
orgId={params.orgId} orgId={params.orgId}
defaultSort={{ rowCount={pagination.total}
id: "name", pagination={{
desc: false pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}} }}
/> />
</OrgProvider> </OrgProvider>

View File

@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
type SitesPageProps = { type SitesPageProps = {
params: Promise<{ orgId: string }>; params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
}; };
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SitesPage(props: SitesPageProps) { export default async function SitesPage(props: SitesPageProps) {
const params = await props.params; const params = await props.params;
const searchParams = new URLSearchParams(await props.searchParams);
let sites: ListSitesResponse["sites"] = []; let sites: ListSitesResponse["sites"] = [];
let pagination: ListSitesResponse["pagination"] = {
total: 0,
page: 1,
pageSize: 20
};
try { try {
const res = await internal.get<AxiosResponse<ListSitesResponse>>( const res = await internal.get<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites`, `/org/${params.orgId}/sites?${searchParams.toString()}`,
await authCookieHeader() await authCookieHeader()
); );
sites = res.data.data.sites; const responseData = res.data.data;
sites = responseData.sites;
pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
const t = await getTranslations(); const t = await getTranslations();
@@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) {
return ( return (
<> <>
{/* <SitesSplashCard /> */}
<SettingsSectionTitle <SettingsSectionTitle
title={t("siteManageSites")} title={t("siteManageSites")}
description={t("siteDescription")} description={t("siteDescription")}
@@ -69,7 +78,15 @@ export default async function SitesPage(props: SitesPageProps) {
<SitesBanner /> <SitesBanner />
<SitesTable sites={siteRows} orgId={params.orgId} /> <SitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</> </>
); );
} }

View File

@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@app/components/ui/button";
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
import UserProfileCard from "@app/components/UserProfileCard";
import { ArrowLeft } from "lucide-react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
type DeleteAccountClientProps = {
displayName: string;
};
export default function DeleteAccountClient({
displayName
}: DeleteAccountClientProps) {
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [isDialogOpen, setIsDialogOpen] = useState(false);
function handleUseDifferentAccount() {
api.post("/auth/logout")
.catch((e) => {
console.error(t("logoutError"), e);
toast({
title: t("logoutError"),
description: formatAxiosError(e, t("logoutError"))
});
})
.then(() => {
router.push(
"/auth/login?internal_redirect=/auth/delete-account"
);
router.refresh();
});
}
return (
<div className="space-y-6">
<UserProfileCard
identifier={displayName}
description={t("signingAs")}
onUseDifferentAccount={handleUseDifferentAccount}
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
/>
<p className="text-sm text-muted-foreground">
{t("deleteAccountDescription")}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t("back")}
</Button>
<Button
variant="destructive"
onClick={() => setIsDialogOpen(true)}
>
{t("deleteAccountButton")}
</Button>
</div>
<DeleteAccountConfirmDialog
open={isDialogOpen}
setOpen={setIsDialogOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { build } from "@server/build";
import { cache } from "react";
import DeleteAccountClient from "./DeleteAccountClient";
import { getTranslations } from "next-intl/server";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export const dynamic = "force-dynamic";
export default async function DeleteAccountPage() {
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) {
redirect("/auth/login");
}
const t = await getTranslations();
const displayName = getUserDisplayName({ user });
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
<DeleteAccountClient displayName={displayName} />
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect"; import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
type ApplyInternalRedirectProps = { type ApplyInternalRedirectProps = {
orgId: string; orgId: string;
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
const path = consumeInternalRedirectPath(); const target = getInternalRedirectTarget(orgId);
if (path) { if (target) {
router.replace(`/${orgId}${path}`); router.replace(target);
} }
}, [orgId, router]); }, [orgId, router]);

View File

@@ -2,16 +2,16 @@
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { import {
approvalFiltersSchema, approvalFiltersSchema,
approvalQueries, approvalQueries,
type ApprovalItem type ApprovalItem
} from "@app/lib/queries"; } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react"; import { Ban, Check, Loader, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -54,12 +54,20 @@ export function ApprovalFeed({
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const { data, isFetching, refetch } = useQuery({ const {
data,
isFetching,
isLoading,
refetch,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery({
...approvalQueries.listApprovals(orgId, filters), ...approvalQueries.listApprovals(orgId, filters),
enabled: isPaidUser(tierMatrix.deviceApprovals) enabled: isPaidUser(tierMatrix.deviceApprovals)
}); });
const approvals = data?.approvals ?? []; const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];
// Show empty state if no approvals are enabled for any role // Show empty state if no approvals are enabled for any role
if (!hasApprovalsEnabled) { if (!hasApprovalsEnabled) {
@@ -115,13 +123,13 @@ export function ApprovalFeed({
onClick={() => { onClick={() => {
refetch(); refetch();
}} }}
disabled={isFetching} disabled={isFetching || isLoading}
className="lg:static gap-2" className="lg:static gap-2"
> >
<RefreshCw <RefreshCw
className={cn( className={cn(
"size-4", "size-4",
isFetching && "animate-spin" (isFetching || isLoading) && "animate-spin"
)} )}
/> />
{t("refresh")} {t("refresh")}
@@ -145,13 +153,30 @@ export function ApprovalFeed({
))} ))}
{approvals.length === 0 && ( {approvals.length === 0 && (
<li className="flex justify-center items-center p-4 text-muted-foreground"> <li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
{t("approvalListEmpty")} {isLoading
? t("loadingApprovals")
: t("approvalListEmpty")}
{isLoading && (
<Loader className="size-4 flex-none animate-spin" />
)}
</li> </li>
)} )}
</ul> </ul>
</CardHeader> </CardHeader>
</Card> </Card>
{hasNextPage && (
<Button
variant="secondary"
className="self-center"
size="lg"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{t("approvalLoadMore")}
</Button>
)}
</div> </div>
); );
} }

View File

@@ -1,9 +1,5 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { startTransition, useActionState, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { import {
Form, Form,
FormControl, FormControl,
@@ -13,6 +9,11 @@ import {
FormLabel, FormLabel,
FormMessage FormMessage
} from "@app/components/ui/form"; } from "@app/components/ui/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { import {
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
@@ -21,19 +22,19 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "./Settings"; } from "./Settings";
import { useTranslations } from "next-intl";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { Input } from "./ui/input";
import { ExternalLink, InfoIcon, XIcon } from "lucide-react";
import { Button } from "./ui/button";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useRouter } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build"; import { build } from "@server/build";
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { XIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert"; import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { validateLocalPath } from "@app/lib/validateLocalPath";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -45,13 +46,36 @@ export type AuthPageCustomizationProps = {
const AuthPageFormSchema = z.object({ const AuthPageFormSchema = z.object({
logoUrl: z.union([ logoUrl: z.union([
z.literal(""), z.literal(""),
z.url("Must be a valid URL").superRefine(async (url, ctx) => { z.string().superRefine(async (urlOrPath, ctx) => {
const parseResult = z.url().safeParse(urlOrPath);
if (!parseResult.success) {
if (build !== "enterprise") {
ctx.addIssue({
code: "custom",
message: "Must be a valid URL"
});
return;
} else {
try {
validateLocalPath(urlOrPath);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
});
} finally {
return;
}
}
}
try { try {
const response = await fetch(url, { const response = await fetch(urlOrPath, {
method: "HEAD" method: "HEAD"
}).catch(() => { }).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET // If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" }); return fetch(urlOrPath, { method: "GET" });
}); });
if (response.status !== 200) { if (response.status !== 200) {
@@ -271,12 +295,25 @@ export default function AuthPageBrandingForm({
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-3"> <FormItem className="md:col-span-3">
<FormLabel> <FormLabel>
{t("brandingLogoURL")} {build === "enterprise"
? t(
"brandingLogoURLOrPath"
)
: t("brandingLogoURL")}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
<FormDescription>
{build === "enterprise"
? t(
"brandingLogoPathDescription"
)
: t(
"brandingLogoURLDescription"
)}
</FormDescription>
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; 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";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
export type InternalResourceRow = { export type InternalResourceRow = {
id: number; id: number;
@@ -51,18 +56,22 @@ export type InternalResourceRow = {
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
internalResources: InternalResourceRow[]; internalResources: InternalResourceRow[];
orgId: string; orgId: string;
defaultSort?: { pagination: PaginationState;
id: string; rowCount: number;
desc: boolean;
};
}; };
export default function ClientResourcesTable({ export default function ClientResourcesTable({
internalResources, internalResources,
orgId, orgId,
defaultSort pagination,
rowCount
}: ClientResourcesTableProps) { }: ClientResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -122,19 +131,7 @@ export default function ClientResourcesTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: ({ column }) => { header: () => <span className="p-3">{t("name")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}, },
{ {
id: "niceId", id: "niceId",
@@ -180,9 +177,24 @@ export default function ClientResourcesTable({
accessorKey: "mode", accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"), friendlyName: t("editInternalResourceDialogMode"),
header: () => ( header: () => (
<span className="p-3"> <ColumnFilterButton
{t("editInternalResourceDialogMode")} options={[
</span> {
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
@@ -300,6 +312,37 @@ export default function ClientResourcesTable({
} }
]; ];
function handleFilterChange(
column: string,
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (value) {
searchParams.set(column, value);
}
filter({
searchParams
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
{selectedInternalResource && ( {selectedInternalResource && (
@@ -327,19 +370,20 @@ export default function ClientResourcesTable({
/> />
)} )}
<DataTable <ControlledDataTable
columns={internalColumns} columns={internalColumns}
data={internalResources} rows={internalResources}
persistPageSize="internal-resources" tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => setIsCreateDialogOpen(true)} onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onSearch={handleSearchChange}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} onPaginationChange={handlePaginationChange}
defaultSort={defaultSort} pagination={pagination}
enableColumnVisibility={true} rowCount={rowCount}
persistColumnVisibility="internal-resources" isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility
columnVisibility={{ columnVisibility={{
niceId: false, niceId: false,
aliasAddress: false aliasAddress: false

View File

@@ -15,6 +15,7 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { Badge } from "./ui/badge";
interface FilterOption { interface FilterOption {
value: string; value: string;
@@ -61,16 +62,19 @@ export function ColumnFilter({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
<span className="truncate">
{selectedOption {selectedOption && (
? selectedOption.label <Badge className="truncate" variant="secondary">
: placeholder} {selectedOption
</span> ? selectedOption.label
: placeholder}
</Badge>
)}
</div> </div>
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" /> <ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start"> <PopoverContent className="p-0 w-50" align="start">
<Command> <Command>
<CommandInput placeholder={searchPlaceholder} /> <CommandInput placeholder={searchPlaceholder} />
<CommandList> <CommandList>

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { Badge } from "./ui/badge";
interface FilterOption {
value: string;
label: string;
}
interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
label: string;
}
export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
}: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find(
(option) => option.value === selectedValue
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
!selectedValue && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2">
{label}
<Funnel className="size-4 flex-none" />
{selectedOption && (
<Badge className="truncate" variant="secondary">
{selectedOption.label}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{/* Clear filter option */}
{selectedValue && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear filter
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange(
selectedValue === option.value
? undefined
: option.value
);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({
const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
const { data: clientsResponse = [] } = useQuery( const { data: clientsResponse = [] } = useQuery(
orgQueries.clients({ orgQueries.clients({
orgId, orgId
filters: {
filter: "machine"
}
}) })
); );

View File

@@ -0,0 +1,414 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl";
import { Button } from "@app/components/ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import type {
DeleteMyAccountPreviewResponse,
DeleteMyAccountCodeRequestedResponse,
DeleteMyAccountSuccessResponse
} from "@server/routers/auth/deleteMyAccount";
import { AxiosResponse } from "axios";
type DeleteAccountConfirmDialogProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
export default function DeleteAccountConfirmDialog({
open,
setOpen
}: DeleteAccountConfirmDialogProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const t = useTranslations();
const passwordSchema = useMemo(
() =>
z.object({
password: z.string().min(1, { message: t("passwordRequired") })
}),
[t]
);
const codeSchema = useMemo(
() =>
z.object({
code: z.string().length(6, { message: t("pincodeInvalid") })
}),
[t]
);
const [step, setStep] = useState<0 | 1 | 2>(0);
const [loading, setLoading] = useState(false);
const [loadingPreview, setLoadingPreview] = useState(false);
const [preview, setPreview] =
useState<DeleteMyAccountPreviewResponse | null>(null);
const [passwordValue, setPasswordValue] = useState("");
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
resolver: zodResolver(passwordSchema),
defaultValues: { password: "" }
});
const codeForm = useForm<z.infer<typeof codeSchema>>({
resolver: zodResolver(codeSchema),
defaultValues: { code: "" }
});
useEffect(() => {
if (open && step === 0 && !preview) {
setLoadingPreview(true);
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
"/auth/delete-my-account",
{}
)
.then((res) => {
if (res.data?.data?.preview) {
setPreview(res.data.data);
}
})
.catch((err) => {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(
err,
t("deleteAccountError")
)
});
setOpen(false);
})
.finally(() => setLoadingPreview(false));
}
}, [open, step, preview, api, setOpen, t]);
function reset() {
setStep(0);
setPreview(null);
setPasswordValue("");
passwordForm.reset();
codeForm.reset();
}
async function handleContinueToPassword() {
setStep(1);
}
async function handlePasswordSubmit(
values: z.infer<typeof passwordSchema>
) {
setLoading(true);
setPasswordValue(values.password);
try {
const res = await api.post<
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
| AxiosResponse<DeleteMyAccountSuccessResponse>
>("/auth/delete-my-account", { password: values.password });
const data = res.data?.data;
if (data && "codeRequested" in data && data.codeRequested) {
setStep(2);
} else if (data && "success" in data && data.success) {
toast({
title: t("deleteAccountSuccess"),
description: t("deleteAccountSuccessMessage")
});
setOpen(false);
reset();
router.push("/auth/login");
router.refresh();
}
} catch (err) {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(err, t("deleteAccountError"))
});
} finally {
setLoading(false);
}
}
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
setLoading(true);
try {
const res = await api.post<
AxiosResponse<DeleteMyAccountSuccessResponse>
>("/auth/delete-my-account", {
password: passwordValue,
code: values.code
});
if (res.data?.data?.success) {
toast({
title: t("deleteAccountSuccess"),
description: t("deleteAccountSuccessMessage")
});
setOpen(false);
reset();
router.push("/auth/login");
router.refresh();
}
} catch (err) {
toast({
variant: "destructive",
title: t("deleteAccountError"),
description: formatAxiosError(err, t("deleteAccountError"))
});
} finally {
setLoading(false);
}
}
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("deleteAccountConfirmTitle")}
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{step === 0 && (
<>
{loadingPreview ? (
<p className="text-sm text-muted-foreground">
{t("loading")}...
</p>
) : preview ? (
<>
<p className="text-sm text-muted-foreground">
{t("deleteAccountConfirmMessage")}
</p>
<div className="rounded-md bg-muted p-3 space-y-2">
<p className="text-sm font-medium">
{t(
"deleteAccountPreviewAccount"
)}
</p>
{preview.orgs.length > 0 && (
<>
<p className="text-sm font-medium mt-2">
{t(
"deleteAccountPreviewOrgs"
)}
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
{preview.orgs.map(
(org) => (
<li
key={
org.orgId
}
>
{org.name ||
org.orgId}
</li>
)
)}
</ul>
</>
)}
</div>
<p className="text-sm font-bold text-destructive">
{t("cannotbeUndone")}
</p>
</>
) : null}
</>
)}
{step === 1 && (
<Form {...passwordForm}>
<form
id="delete-account-password-form"
onSubmit={passwordForm.handleSubmit(
handlePasswordSubmit
)}
className="space-y-4"
>
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("password")}
</FormLabel>
<FormControl>
<Input
type="password"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<div className="text-center">
<p className="text-sm text-muted-foreground">
{t("otpAuthDescription")}
</p>
</div>
<Form {...codeForm}>
<form
id="delete-account-code-form"
onSubmit={codeForm.handleSubmit(
handleCodeSubmit
)}
className="space-y-4"
>
<FormField
control={codeForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(
value: string
) => {
field.onChange(
value
);
}}
>
<InputOTPGroup>
<InputOTPSlot
index={
0
}
/>
<InputOTPSlot
index={
1
}
/>
<InputOTPSlot
index={
2
}
/>
<InputOTPSlot
index={
3
}
/>
<InputOTPSlot
index={
4
}
/>
<InputOTPSlot
index={
5
}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{step === 0 && preview && !loadingPreview && (
<Button
variant="destructive"
onClick={handleContinueToPassword}
>
{t("continue")}
</Button>
)}
{step === 1 && (
<Button
variant="destructive"
type="submit"
form="delete-account-password-form"
loading={loading}
disabled={loading}
>
{t("deleteAccountButton")}
</Button>
)}
{step === 2 && (
<Button
variant="destructive"
type="submit"
form="delete-account-code-form"
loading={loading}
disabled={loading}
>
{t("deleteAccountButton")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({
orgQueries.roles({ orgId }), orgQueries.roles({ orgId }),
orgQueries.users({ orgId }), orgQueries.users({ orgId }),
orgQueries.clients({ orgQueries.clients({
orgId, orgId
filters: {
filter: "machine"
}
}), }),
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),

View File

@@ -16,13 +16,23 @@ import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
MoreHorizontal, MoreHorizontal,
CircleSlash CircleSlash,
ArrowDown01Icon,
ArrowUp10Icon,
ChevronsUpDownIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { ColumnFilterButton } from "./ColumnFilterButton";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -48,14 +58,24 @@ export type ClientRow = {
type ClientTableProps = { type ClientTableProps = {
machineClients: ClientRow[]; machineClients: ClientRow[];
orgId: string; orgId: string;
pagination: PaginationState;
rowCount: number;
}; };
export default function MachineClientsTable({ export default function MachineClientsTable({
machineClients, machineClients,
orgId orgId,
pagination,
rowCount
}: ClientTableProps) { }: ClientTableProps) {
const router = useRouter(); const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations(); const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@@ -65,6 +85,7 @@ export default function MachineClientsTable({
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const defaultMachineColumnVisibility = { const defaultMachineColumnVisibility = {
subnet: false, subnet: false,
@@ -182,22 +203,8 @@ export default function MachineClientsTable({
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: "Name", friendlyName: t("name"),
header: ({ column }) => { header: () => <span className="px-3">{t("name")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return ( return (
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
{ {
accessorKey: "niceId", accessorKey: "niceId",
friendlyName: "Identifier", friendlyName: "Identifier",
header: ({ column }) => { header: () => <span className="px-3">{t("identifier")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: "Connectivity", friendlyName: t("online"),
header: ({ column }) => { header: () => {
return ( return (
<Button <ColumnFilterButton
variant="ghost" options={[
onClick={() => {
column.toggleSorting( value: "true",
column.getIsSorted() === "asc" label: t("connected")
) },
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
} }
> onValueChange={(value) =>
Connectivity handleFilterChange("online", value)
<ArrowUpDown className="ml-2 h-4 w-4" /> }
</Button> searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
@@ -279,38 +283,52 @@ export default function MachineClientsTable({
}, },
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: "Data In", friendlyName: t("dataIn"),
header: ({ column }) => { header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesIn")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
Data In {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: "Data Out", friendlyName: t("dataOut"),
header: ({ column }) => { header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesOut")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
Data Out {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
@@ -318,21 +336,7 @@ export default function MachineClientsTable({
{ {
accessorKey: "client", accessorKey: "client",
friendlyName: t("agent"), friendlyName: t("agent"),
header: ({ column }) => { header: () => <span className="px-3">{t("agent")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("agent")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -356,22 +360,8 @@ export default function MachineClientsTable({
}, },
{ {
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: "Address", friendlyName: t("address"),
header: ({ column }) => { header: () => <span className="px-3">{t("address")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
} }
]; ];
@@ -455,7 +445,56 @@ export default function MachineClientsTable({
} }
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, t]); }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | null | undefined | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
for (const val of value) {
searchParams.append(column, val);
}
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
@@ -478,20 +517,25 @@ export default function MachineClientsTable({
title="Delete Client" title="Delete Client"
/> />
)} )}
<DataTable <ControlledDataTable
columns={columns} columns={columns}
data={machineClients || []} rows={machineClients}
persistPageSize="machine-clients" tableId="machine-clients"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => onAdd={() =>
router.push(`/${orgId}/settings/clients/machine/create`) startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`)
)
} }
pagination={pagination}
rowCount={rowCount}
addButtonText={t("createClient")} addButtonText={t("createClient")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility={true} onSearch={handleSearchChange}
persistColumnVisibility="machine-clients" onPaginationChange={handlePaginationChange}
isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility
columnVisibility={defaultMachineColumnVisibility} columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
@@ -518,30 +562,10 @@ export default function MachineClientsTable({
value: "blocked" value: "blocked"
} }
], ],
filterFn: ( onValueChange(selectedValues: string[]) {
row: ClientRow, handleFilterChange("status", selectedValues);
selectedValues: (string | number | boolean)[]
) => {
if (selectedValues.length === 0) return true;
const rowArchived = row.archived || false;
const rowBlocked = row.blocked || false;
const isActive = !rowArchived && !rowBlocked;
if (selectedValues.includes("active") && isActive)
return true;
if (
selectedValues.includes("archived") &&
rowArchived
)
return true;
if (
selectedValues.includes("blocked") &&
rowBlocked
)
return true;
return false;
}, },
defaultValues: ["active"] // Default to showing active clients values: searchParams.getAll("status")
} }
]} ]}
/> />

View File

@@ -83,7 +83,7 @@ export function OrgSelector({
<PopoverContent className="w-[320px] p-0" align="start"> <PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg"> <Command className="rounded-lg">
<CommandInput <CommandInput
placeholder={t("searchProgress")} placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0" className="border-0 focus:ring-0"
/> />
<CommandEmpty className="py-6 text-center"> <CommandEmpty className="py-6 text-center">

View File

@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react"; import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { build } from "@server/build";
import { useState } from "react"; import { useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext"; import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm"; import Disable2FaForm from "./Disable2FaForm";
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<LocaleSwitcher /> <LocaleSwitcher />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{user?.type === UserType.Internal && !user?.serverAdmin && (
<>
<DropdownMenuItem asChild>
<Link
href="/auth/delete-account"
className="flex cursor-pointer items-center"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>{t("deleteAccount")}</span>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={() => logout()}> <DropdownMenuItem onClick={() => logout()}>
{/* <LogOut className="mr-2 h-4 w-4" /> */} {/* <LogOut className="mr-2 h-4 w-4" /> */}
<span>{t("logout")}</span> <span>{t("logout")}</span>

View File

@@ -2,9 +2,8 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard"; import CopyToClipboard from "@app/components/CopyToClipboard";
import { DataTable } from "@app/components/ui/data-table";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -14,13 +13,14 @@ import {
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { Switch } from "@app/components/ui/switch"; import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { UpdateResourceResponse } from "@server/routers/resource"; import { UpdateResourceResponse } from "@server/routers/resource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { import {
ArrowRight, ArrowRight,
ArrowUpDown,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
Clock, Clock,
@@ -32,14 +32,24 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState, useTransition } from "react"; import {
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { ControlledDataTable } from "./ui/controlled-data-table";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
ip: string; ip: string;
port: number; port: number;
enabled: boolean; enabled: boolean;
healthStatus?: "healthy" | "unhealthy" | "unknown"; healthStatus: "healthy" | "unhealthy" | "unknown" | null;
}; };
export type ResourceRow = { export type ResourceRow = {
@@ -117,18 +127,22 @@ function StatusIcon({
type ProxyResourcesTableProps = { type ProxyResourcesTableProps = {
resources: ResourceRow[]; resources: ResourceRow[];
orgId: string; orgId: string;
defaultSort?: { pagination: PaginationState;
id: string; rowCount: number;
desc: boolean;
};
}; };
export default function ProxyResourcesTable({ export default function ProxyResourcesTable({
resources, resources,
orgId, orgId,
defaultSort pagination,
rowCount
}: ProxyResourcesTableProps) { }: ProxyResourcesTableProps) {
const router = useRouter(); const router = useRouter();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext(); const { env } = useEnvContext();
@@ -140,6 +154,7 @@ export default function ProxyResourcesTable({
useState<ResourceRow | null>(); useState<ResourceRow | null>();
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
const refreshData = () => { const refreshData = () => {
startTransition(() => { startTransition(() => {
@@ -174,23 +189,24 @@ export default function ProxyResourcesTable({
}; };
async function toggleResourceEnabled(val: boolean, resourceId: number) { async function toggleResourceEnabled(val: boolean, resourceId: number) {
await api try {
.post<AxiosResponse<UpdateResourceResponse>>( await api.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resourceId}`, `resource/${resourceId}`,
{ {
enabled: val enabled: val
} }
) );
.catch((e) => { router.refresh();
toast({ } catch (e) {
variant: "destructive", toast({
title: t("resourcesErrorUpdate"), variant: "destructive",
description: formatAxiosError( title: t("resourcesErrorUpdate"),
e, description: formatAxiosError(
t("resourcesErrorUpdateDescription") e,
) t("resourcesErrorUpdateDescription")
}); )
}); });
}
} }
function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) {
@@ -236,7 +252,7 @@ export default function ProxyResourcesTable({
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]"> <DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && ( {monitoredTargets.length > 0 && (
<> <>
{monitoredTargets.map((target) => ( {monitoredTargets.map((target) => (
@@ -302,38 +318,14 @@ export default function ProxyResourcesTable({
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: ({ column }) => { header: () => <span className="p-3">{t("name")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}, },
{ {
id: "niceId", id: "niceId",
accessorKey: "nice", accessorKey: "nice",
friendlyName: t("identifier"), friendlyName: t("identifier"),
enableHiding: true, enableHiding: true,
header: ({ column }) => { header: () => <span className="p-3">{t("identifier")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>; return <span>{row.original.nice || "-"}</span>;
} }
@@ -359,19 +351,33 @@ export default function ProxyResourcesTable({
id: "status", id: "status",
accessorKey: "status", accessorKey: "status",
friendlyName: t("status"), friendlyName: t("status"),
header: ({ column }) => { header: () => (
return ( <ColumnFilterButton
<Button options={[
variant="ghost" { value: "healthy", label: t("resourcesTableHealthy") },
onClick={() => {
column.toggleSorting(column.getIsSorted() === "asc") value: "degraded",
} label: t("resourcesTableDegraded")
> },
{t("status")} { value: "offline", label: t("resourcesTableOffline") },
<ArrowUpDown className="ml-2 h-4 w-4" /> {
</Button> value: "no_targets",
); label: t("resourcesTableNoTargets")
}, },
{ value: "unknown", label: t("resourcesTableUnknown") }
]}
selectedValue={
searchParams.get("healthStatus") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("healthStatus", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
className="p-3"
/>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />; return <TargetStatusCell targets={resourceRow.targets} />;
@@ -419,19 +425,23 @@ export default function ProxyResourcesTable({
{ {
accessorKey: "authState", accessorKey: "authState",
friendlyName: t("authentication"), friendlyName: t("authentication"),
header: ({ column }) => { header: () => (
return ( <ColumnFilterButton
<Button options={[
variant="ghost" { value: "protected", label: t("protected") },
onClick={() => { value: "not_protected", label: t("notProtected") },
column.toggleSorting(column.getIsSorted() === "asc") { value: "none", label: t("none") }
} ]}
> selectedValue={searchParams.get("authState") ?? undefined}
{t("authentication")} onValueChange={(value) =>
<ArrowUpDown className="ml-2 h-4 w-4" /> handleFilterChange("authState", value)
</Button> }
); searchPlaceholder={t("searchPlaceholder")}
}, emptyMessage={t("emptySearchOptions")}
label={t("authentication")}
className="p-3"
/>
),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
return ( return (
@@ -456,20 +466,28 @@ export default function ProxyResourcesTable({
{ {
accessorKey: "enabled", accessorKey: "enabled",
friendlyName: t("enabled"), friendlyName: t("enabled"),
header: () => <span className="p-3">{t("enabled")}</span>, header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => ( cell: ({ row }) => (
<Switch <ResourceEnabledForm
defaultChecked={ resource={row.original}
row.original.http onToggleResourceEnabled={toggleResourceEnabled}
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
/> />
) )
}, },
@@ -525,6 +543,42 @@ export default function ProxyResourcesTable({
} }
]; ];
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange(
column: string,
value: string | undefined | null
) {
searchParams.delete(column);
searchParams.delete("page");
if (value) {
searchParams.set(column, value);
}
filter({
searchParams
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
{selectedResource && ( {selectedResource && (
@@ -547,21 +601,25 @@ export default function ProxyResourcesTable({
/> />
)} )}
<DataTable <ControlledDataTable
columns={proxyColumns} columns={proxyColumns}
data={resources} rows={resources}
persistPageSize="proxy-resources" tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name" pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>
router.push(`/${orgId}/settings/resources/proxy/create`) startNavigation(() =>
router.push(`/${orgId}/settings/resources/proxy/create`)
)
} }
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
defaultSort={defaultSort} isNavigatingToAddPage={isNavigatingToAddPage}
enableColumnVisibility={true} enableColumnVisibility
persistColumnVisibility="proxy-resources"
columnVisibility={{ niceId: false }} columnVisibility={{ niceId: false }}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
@@ -569,3 +627,43 @@ export default function ProxyResourcesTable({
</> </>
); );
} }
type ResourceEnabledFormProps = {
resource: ResourceRow;
onToggleResourceEnabled: (
val: boolean,
resourceId: number
) => Promise<void>;
};
function ResourceEnabledForm({
resource,
onToggleResourceEnabled
}: ResourceEnabledFormProps) {
const enabled = resource.http
? !!resource.domainId && resource.enabled
: resource.enabled;
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={
(resource.http && !resource.domainId) ||
optimisticEnabled !== enabled
}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}

View File

@@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
useEffect(() => { useEffect(() => {
try { try {
const target = getInternalRedirectTarget(targetOrgId); const target =
getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`;
router.replace(target); router.replace(target);
} catch { } catch {
router.replace(`/${targetOrgId}`); router.replace(`/${targetOrgId}`);

View File

@@ -1,50 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function SitesDataTable<TData, TValue>({
columns,
data,
createSite,
onRefresh,
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="sites-table"
title={t("sites")}
searchPlaceholder={t("searchSitesProgress")}
searchColumn="name"
onAdd={createSite}
addButtonText={t("siteAdd")}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}

View File

@@ -1,37 +1,42 @@
"use client"; "use client";
import { Column, ColumnDef } from "@tanstack/react-table"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { SitesDataTable } from "@app/components/SitesDataTable"; import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Check,
MoreHorizontal,
X
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AxiosResponse } from "axios";
import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { parseDataSize } from "@app/lib/dataSize";
import { Badge } from "@app/components/ui/badge";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { build } from "@server/build"; import { build } from "@server/build";
import { type PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon,
ArrowUpRight,
ChevronsUpDownIcon,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "./ui/controlled-data-table";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@@ -52,79 +57,91 @@ export type SiteRow = {
type SitesTableProps = { type SitesTableProps = {
sites: SiteRow[]; sites: SiteRow[];
pagination: PaginationState;
orgId: string; orgId: string;
rowCount: number;
}; };
export default function SitesTable({ sites, orgId }: SitesTableProps) { export default function SitesTable({
sites,
orgId,
pagination,
rowCount
}: SitesTableProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [rows, setRows] = useState<SiteRow[]>(sites); const [isRefreshing, startTransition] = useTransition();
const [isRefreshing, setIsRefreshing] = useState(false); const [isNavigatingToAddPage, startNavigation] = useTransition();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
// Update local state when props change (e.g., after refresh) const booleanSearchFilterSchema = z
useEffect(() => { .enum(["true", "false"])
setRows(sites); .optional()
}, [sites]); .catch(undefined);
const refreshData = async () => { function handleFilterChange(
console.log("Data refreshed"); column: string,
setIsRefreshing(true); value: string | undefined | null
try { ) {
await new Promise((resolve) => setTimeout(resolve, 200)); const sp = new URLSearchParams(searchParams);
router.refresh(); sp.delete(column);
} catch (error) { sp.delete("page");
toast({
title: t("error"), if (value) {
description: t("refreshError"), sp.set(column, value);
variant: "destructive"
});
} finally {
setIsRefreshing(false);
} }
}; startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}
const deleteSite = (siteId: number) => { function refreshData() {
api.delete(`/site/${siteId}`) startTransition(async () => {
.catch((e) => { try {
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
router.refresh(); router.refresh();
setIsDeleteModalOpen(false); } catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
}
});
}
const newRows = rows.filter((row) => row.id !== siteId); function deleteSite(siteId: number) {
startTransition(async () => {
setRows(newRows); await api
}); .delete(`/site/${siteId}`)
}; .catch((e) => {
console.error(t("siteErrorDelete"), e);
toast({
variant: "destructive",
title: t("siteErrorDelete"),
description: formatAxiosError(e, t("siteErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
});
});
}
const columns: ExtendedColumnDef<SiteRow>[] = [ const columns: ExtendedColumnDef<SiteRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
header: ({ column }) => { header: () => {
return ( return <span className="p-3">{t("name")}</span>;
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
} }
}, },
{ {
@@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
accessorKey: "nice", accessorKey: "nice",
friendlyName: t("identifier"), friendlyName: t("identifier"),
enableHiding: true, enableHiding: true,
header: ({ column }) => { header: () => {
return ( return <span className="p-3">{t("identifier")}</span>;
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>; return <span>{row.original.nice || "-"}</span>;
@@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: t("online"), friendlyName: t("online"),
header: ({ column }) => { header: () => {
return ( return (
<Button <ColumnFilterButton
variant="ghost" options={[
onClick={() => { value: "true", label: t("online") },
column.toggleSorting(column.getIsSorted() === "asc") { value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
handleFilterChange("online", value)
} }
> searchPlaceholder={t("searchPlaceholder")}
{t("online")} emptyMessage={t("emptySearchOptions")}
<ArrowUpDown className="ml-2 h-4 w-4" /> label={t("online")}
</Button> className="p-3"
/>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
@@ -194,58 +208,59 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: t("dataIn"), friendlyName: t("dataIn"),
header: ({ column }) => { header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesIn")}
column.toggleSorting(column.getIsSorted() === "asc")
}
> >
{t("dataIn")} {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, }
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbIn) -
parseDataSize(rowB.original.mbIn)
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: t("dataOut"), friendlyName: t("dataOut"),
header: ({ column }) => { header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesOut")}
column.toggleSorting(column.getIsSorted() === "asc")
}
> >
{t("dataOut")} {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
}, }
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbOut) -
parseDataSize(rowB.original.mbOut)
}, },
{ {
accessorKey: "type", accessorKey: "type",
friendlyName: t("type"), friendlyName: t("type"),
header: ({ column }) => { header: () => {
return ( return <span className="p-3">{t("type")}</span>;
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -290,18 +305,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
{ {
accessorKey: "exitNode", accessorKey: "exitNode",
friendlyName: t("exitNode"), friendlyName: t("exitNode"),
header: ({ column }) => { header: () => {
return ( return <span className="p-3">{t("exitNode")}</span>;
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("exitNode")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
{ {
accessorKey: "address", accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => { header: () => {
return ( return <span className="p-3">{t("address")}</span>;
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}, },
cell: ({ row }: { row: any }) => { cell: ({ row }: { row: any }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -428,6 +423,30 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
} }
]; ];
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
{selectedSite && ( {selectedSite && (
@@ -444,27 +463,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
</div> </div>
} }
buttonText={t("siteConfirmDelete")} buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)} onConfirm={async () =>
startTransition(() => deleteSite(selectedSite!.id))
}
string={selectedSite.name} string={selectedSite.name}
title={t("siteDelete")} title={t("siteDelete")}
/> />
)} )}
<SitesDataTable <ControlledDataTable
columns={columns} columns={columns}
data={rows} rows={sites}
createSite={() => tableId="sites-table"
router.push(`/${orgId}/settings/sites/create`) searchPlaceholder={t("searchSitesProgress")}
pagination={pagination}
onPaginationChange={handlePaginationChange}
onAdd={() =>
startNavigation(() =>
router.push(`/${orgId}/settings/sites/create`)
)
} }
isNavigatingToAddPage={isNavigatingToAddPage}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange}
addButtonText={t("siteAdd")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
rowCount={rowCount}
columnVisibility={{ columnVisibility={{
niceId: false, niceId: false,
nice: false, nice: false,
exitNode: false, exitNode: false,
address: false address: false
}} }}
enableColumnVisibility={true} enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="actions"
/> />
</> </>
); );

View File

@@ -1,11 +1,13 @@
"use client"; "use client";
import * as React from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { QueryClient } from "@tanstack/react-query";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { durationToMs } from "@app/lib/durationToMs"; import {
keepPreviousData,
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import * as React from "react";
export type ReactQueryProviderProps = { export type ReactQueryProviderProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) {
staleTime: 0, staleTime: 0,
meta: { meta: {
api api
} },
placeholderData: keepPreviousData
}, },
mutations: { mutations: {
meta: { api } meta: { api }

View File

@@ -2,34 +2,41 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { formatFingerprintInfo, formatPlatform } from "@app/lib/formatDeviceFingerprint"; import { build } from "@server/build";
import type { PaginationState } from "@tanstack/react-table";
import { import {
ArrowDown01Icon,
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUp10Icon,
ArrowUpRight, ArrowUpRight,
MoreHorizontal, ChevronsUpDownIcon,
CircleSlash CircleSlash,
MoreHorizontal
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo, useState, useTransition } from "react"; import { useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import ClientDownloadBanner from "./ClientDownloadBanner"; import ClientDownloadBanner from "./ClientDownloadBanner";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { build } from "@server/build"; import { ControlledDataTable } from "./ui/controlled-data-table";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { InfoPopup } from "@app/components/ui/info-popup";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -65,9 +72,15 @@ export type ClientRow = {
type ClientTableProps = { type ClientTableProps = {
userClients: ClientRow[]; userClients: ClientRow[];
orgId: string; orgId: string;
pagination: PaginationState;
rowCount: number;
}; };
export default function UserDevicesTable({ userClients }: ClientTableProps) { export default function UserDevicesTable({
userClients,
pagination,
rowCount
}: ClientTableProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
@@ -77,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
); );
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const defaultUserColumnVisibility = { const defaultUserColumnVisibility = {
@@ -188,8 +206,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
try { try {
// Fetch approvalId for this client using clientId query parameter // Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{ const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> }; data: {
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0]; const approval = approvalsRes.data.data.approvals[0];
@@ -202,9 +224,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return; return;
} }
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { await api.put(
decision: "approved" `/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
}); {
decision: "approved"
}
);
toast({ toast({
title: t("accessApprovalUpdated"), title: t("accessApprovalUpdated"),
@@ -230,8 +255,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
try { try {
// Fetch approvalId for this client using clientId query parameter // Fetch approvalId for this client using clientId query parameter
const approvalsRes = await api.get<{ const approvalsRes = await api.get<{
data: { approvals: Array<{ approvalId: number; clientId: number }> }; data: {
}>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); approvals: Array<{ approvalId: number; clientId: number }>;
};
}>(
`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`
);
const approval = approvalsRes.data.data.approvals[0]; const approval = approvalsRes.data.data.approvals[0];
@@ -244,9 +273,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
return; return;
} }
await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { await api.put(
decision: "denied" `/org/${clientRow.orgId}/approvals/${approval.approvalId}`,
}); {
decision: "denied"
}
);
toast({ toast({
title: t("accessApprovalUpdated"), title: t("accessApprovalUpdated"),
@@ -279,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
friendlyName: t("name"), friendlyName: t("name"),
header: ({ column }) => { header: () => <span className="px-3">{t("name")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
const fingerprintInfo = r.fingerprint const fingerprintInfo = r.fingerprint
@@ -343,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "niceId", accessorKey: "niceId",
friendlyName: t("identifier"), friendlyName: t("identifier"),
header: ({ column }) => { header: () => <span className="px-3">{t("identifier")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
}, },
{ {
accessorKey: "userEmail", accessorKey: "userEmail",
friendlyName: t("users"), friendlyName: t("users"),
header: ({ column }) => { header: () => <span className="px-3">{t("users")}</span>,
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => { cell: ({ row }) => {
const r = row.original; const r = row.original;
return r.userId ? ( return r.userId ? (
@@ -398,20 +388,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}, },
{ {
accessorKey: "online", accessorKey: "online",
friendlyName: t("connectivity"), friendlyName: t("online"),
header: ({ column }) => { header: () => {
return ( return (
<Button <ColumnFilterButton
variant="ghost" options={[
onClick={() => {
column.toggleSorting( value: "true",
column.getIsSorted() === "asc" label: t("connected")
) },
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
} }
> onValueChange={(value) =>
{t("online")} handleFilterChange("online", value)
<ArrowUpDown className="ml-2 h-4 w-4" /> }
</Button> searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("online")}
className="p-3"
/>
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
@@ -436,18 +437,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
friendlyName: t("dataIn"), friendlyName: t("dataIn"),
header: ({ column }) => { header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesIn")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
{t("dataIn")} {t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
@@ -455,18 +463,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
friendlyName: t("dataOut"), friendlyName: t("dataOut"),
header: ({ column }) => { header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => onClick={() => toggleSort("megabytesOut")}
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
> >
{t("dataOut")} {t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
@@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "client", accessorKey: "client",
friendlyName: t("agent"), friendlyName: t("agent"),
header: ({ column }) => { header: () => (
return ( <ColumnFilterButton
<Button options={[
variant="ghost" {
onClick={() => value: "macos",
column.toggleSorting( label: "Pangolin macOS"
column.getIsSorted() === "asc" },
) {
value: "ios",
label: "Pangolin iOS"
},
{
value: "ipados",
label: "Pangolin iPadOS"
},
{
value: "android",
label: "Pangolin Android"
},
{
value: "windows",
label: "Pangolin Windows"
},
{
value: "cli",
label: "Pangolin CLI"
},
{
value: "olm",
label: "Olm CLI"
},
{
value: "unknown",
label: t("unknown")
} }
> ]}
{t("agent")} selectedValue={searchParams.get("agent") ?? undefined}
<ArrowUpDown className="ml-2 h-4 w-4" /> onValueChange={(value) =>
</Button> handleFilterChange("agent", value)
); }
}, searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("agent")}
className="p-3"
/>
),
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -514,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{ {
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: t("address"), friendlyName: t("address"),
header: ({ column }) => { header: () => <span className="px-3">{t("address")}</span>
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
} }
]; ];
@@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{clientRow.approvalState === "pending" && ( {clientRow.approvalState === "pending" &&
<> build !== "oss" && (
<DropdownMenuItem <>
onClick={() => approveDevice(clientRow)} <DropdownMenuItem
> onClick={() =>
<span>{t("approve")}</span> approveDevice(clientRow)
</DropdownMenuItem> }
<DropdownMenuItem >
onClick={() => denyDevice(clientRow)} <span>{t("approve")}</span>
> </DropdownMenuItem>
<span>{t("deny")}</span> <DropdownMenuItem
</DropdownMenuItem> onClick={() =>
</> denyDevice(clientRow)
)} }
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (clientRow.archived) { if (clientRow.archived) {
@@ -621,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
}); });
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, t]); }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
const statusFilterOptions = useMemo(() => { const statusFilterOptions = useMemo(() => {
const allOptions = [ const allOptions = [
@@ -652,12 +689,59 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
} }
]; ];
if (build === "oss") {
return allOptions.filter(
(option) =>
option.value !== "pending" && option.value !== "denied"
);
}
return allOptions; return allOptions;
}, [t]); }, [t]);
const statusFilterDefaultValues = useMemo(() => { function handleFilterChange(
return ["active", "pending"]; column: string,
}, []); value: string | null | undefined | string[]
) {
searchParams.delete(column);
searchParams.delete("page");
if (typeof value === "string") {
searchParams.set(column, value);
} else if (value) {
for (const val of value) {
searchParams.append(column, val);
}
}
filter({
searchParams
});
}
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
filter({
searchParams: newSearch
});
}
const handlePaginationChange = (newPage: PaginationState) => {
searchParams.set("page", (newPage.pageIndex + 1).toString());
searchParams.set("pageSize", newPage.pageSize.toString());
filter({
searchParams
});
};
const handleSearchChange = useDebouncedCallback((query: string) => {
searchParams.set("query", query);
searchParams.delete("page");
filter({
searchParams
});
}, 300);
return ( return (
<> <>
@@ -682,17 +766,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
)} )}
<ClientDownloadBanner /> <ClientDownloadBanner />
<DataTable <ControlledDataTable
columns={columns} columns={columns}
data={userClients || []} rows={userClients || []}
persistPageSize="user-clients" tableId="user-clients"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility={true} enableColumnVisibility
persistColumnVisibility="user-clients"
columnVisibility={defaultUserColumnVisibility} columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
pagination={pagination}
rowCount={rowCount}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
filters={[ filters={[
@@ -702,41 +788,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
multiSelect: true, multiSelect: true,
displayMode: "calculated", displayMode: "calculated",
options: statusFilterOptions, options: statusFilterOptions,
filterFn: ( onValueChange: (selectedValues: string[]) => {
row: ClientRow, handleFilterChange("status", selectedValues);
selectedValues: (string | number | boolean)[]
) => {
if (selectedValues.length === 0) return true;
const rowArchived = row.archived;
const rowBlocked = row.blocked;
const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied";
if (selectedValues.includes("active") && isActive)
return true;
if (
selectedValues.includes("pending") &&
approvalState === "pending"
)
return true;
if (
selectedValues.includes("denied") &&
approvalState === "denied"
)
return true;
if (
selectedValues.includes("archived") &&
rowArchived
)
return true;
if (
selectedValues.includes("blocked") &&
rowBlocked
)
return true;
return false;
}, },
defaultValues: statusFilterDefaultValues values: searchParams.getAll("status")
} }
]} ]}
/> />

View File

@@ -20,6 +20,7 @@ import {
import { Input } from "./ui/input"; import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
import { useEffect } from "react";
type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number];

View File

@@ -0,0 +1,592 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
PaginationState,
useReactTable
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
TData,
TValue
> & {
friendlyName?: string;
};
type FilterOption = {
id: string;
label: string;
value: string;
};
type DataTableFilter = {
id: string;
label: string;
options: FilterOption[];
multiSelect?: boolean;
onValueChange: (selectedValues: string[]) => void;
values?: string[];
displayMode?: "label" | "calculated"; // How to display the filter button text
};
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type ControlledDataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
rows: TData[];
tableId: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
onSearch?: (input: string) => void;
searchQuery?: string;
onPaginationChange: DataTablePaginationUpdateFn;
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
rowCount: number;
pagination: PaginationState;
};
export function ControlledDataTable<TData, TValue>({
columns,
rows,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
tableId,
pagination,
stickyLeftColumn,
onSearch,
searchQuery,
onPaginationChange,
stickyRightColumn,
rowCount,
isNavigatingToAddPage
}: ControlledDataTableProps<TData, TValue>) {
const t = useTranslations();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility(
tableId,
defaultColumnVisibility
);
// TODO: filters
const activeFilters = useMemo(() => {
const initial: Record<string, string[]> = {};
filters?.forEach((filter) => {
initial[filter.id] = filter.values || [];
});
return initial;
}, [filters]);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
// getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (state) => {
const newState =
typeof state === "function" ? state(pagination) : state;
onPaginationChange(newState);
},
manualFiltering: true,
manualPagination: true,
rowCount,
state: {
columnFilters,
columnVisibility,
pagination
}
});
// Calculate display text for a filter based on selected values
const getFilterDisplayText = (filter: DataTableFilter): string => {
const selectedValues = activeFilters[filter.id] || [];
if (selectedValues.length === 0) {
return filter.label;
}
const selectedOptions = filter.options.filter((option) =>
selectedValues.includes(option.value)
);
if (selectedOptions.length === 0) {
return filter.label;
}
if (selectedOptions.length === 1) {
return selectedOptions[0].label;
}
// Multiple selections: always join with "and"
return selectedOptions.map((opt) => opt.label).join(" or ");
};
const handleFilterChange = (
filterId: string,
optionValue: string,
checked: boolean
) => {
const currentValues = activeFilters[filterId] || [];
const filter = filters?.find((f) => f.id === filterId);
if (!filter) return;
let newValues: string[];
if (filter.multiSelect) {
// Multi-select: add or remove the value
if (checked) {
newValues = [...currentValues, optionValue];
} else {
newValues = currentValues.filter((v) => v !== optionValue);
}
} else {
// Single-select: replace the value
newValues = checked ? [optionValue] : [];
}
filter.onValueChange(newValues);
};
// Helper function to check if a column should be sticky
const isStickyColumn = (
columnId: string | undefined,
accessorKey: string | undefined,
position: "left" | "right"
): boolean => {
if (position === "left" && stickyLeftColumn) {
return (
columnId === stickyLeftColumn ||
accessorKey === stickyLeftColumn
);
}
if (position === "right" && stickyRightColumn) {
return (
columnId === stickyRightColumn ||
accessorKey === stickyRightColumn
);
}
return false;
};
// Get sticky column classes
const getStickyClasses = (
columnId: string | undefined,
accessorKey: string | undefined
): string => {
if (isStickyColumn(columnId, accessorKey, "left")) {
return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]";
}
if (isStickyColumn(columnId, accessorKey, "right")) {
return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]";
}
return "";
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{onSearch && (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
defaultValue={searchQuery}
onChange={(e) =>
onSearch(e.currentTarget.value)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
)}
{filters && filters.length > 0 && (
<div className="flex gap-2">
{filters.map((filter) => {
const selectedValues =
activeFilters[filter.id] || [];
const hasActiveFilters =
selectedValues.length > 0;
const displayMode =
filter.displayMode || filterDisplayMode;
const displayText =
displayMode === "calculated"
? getFilterDisplayText(filter)
: filter.label;
return (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
size="sm"
className="h-9"
>
<Filter className="h-4 w-4 mr-2" />
{displayText}
{displayMode === "label" &&
hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{
selectedValues.length
}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-48"
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map(
(option) => {
const isChecked =
selectedValues.includes(
option.value
);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={
isChecked
}
onCheckedChange={(
checked
) => {
handleFilterChange(
filter.id,
option.value,
checked
);
}}
onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<div>
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
)}
{onAdd && addButtonText && (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id;
const accessorKey = (
header.column.columnDef as any
).accessorKey as string | undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
const hasHideableColumns =
enableColumnVisibility &&
table
.getAllColumns()
.some((col) =>
col.getCanHide()
);
return (
<TableHead
key={header.id}
className={`whitespace-nowrap ${stickyClasses}`}
>
{header.isPlaceholder ? null : isRightSticky &&
hasHideableColumns ? (
<div className="flex flex-col items-end pr-3">
<DropdownMenu>
<DropdownMenuTrigger
asChild
>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 mb-1"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t(
"columns"
) ||
"Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(
column
) =>
column.getCanHide()
)
.map(
(
column
) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={
column.id
}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(
value
) =>
column.toggleVisibility(
!!value
)
}
onSelect={(
e
) =>
e.preventDefault()
}
>
{
displayName
}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="h-0 opacity-0 pointer-events-none overflow-hidden">
{flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</div>
</div>
) : (
flexRender(
header.column
.columnDef
.header,
header.getContext()
)
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => {
const columnId =
cell.column.id;
const accessorKey = (
cell.column
.columnDef as any
).accessorKey as
| string
| undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
return (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${stickyClasses} ${isRightSticky ? "text-right" : ""}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
{rowCount > 0 && (
<DataTablePagination
table={table}
totalCount={rowCount}
onPageSizeChange={(pageSize) =>
onPaginationChange({
...pagination,
pageSize
})
}
onPageChange={(pageIndex) => {
onPaginationChange({
...pagination,
pageIndex
});
}}
isServerPagination
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -151,11 +151,20 @@ type DataTableFilter = {
label: string; label: string;
options: FilterOption[]; options: FilterOption[];
multiSelect?: boolean; multiSelect?: boolean;
filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; filterFn: (
row: any,
selectedValues: (string | number | boolean)[]
) => boolean;
defaultValues?: (string | number | boolean)[]; defaultValues?: (string | number | boolean)[];
displayMode?: "label" | "calculated"; // How to display the filter button text displayMode?: "label" | "calculated"; // How to display the filter button text
}; };
export type DataTablePaginationState = PaginationState & {
pageCount: number;
};
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type DataTableProps<TData, TValue> = { type DataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[]; columns: ExtendedColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
@@ -178,6 +187,11 @@ type DataTableProps<TData, TValue> = {
defaultPageSize?: number; defaultPageSize?: number;
columnVisibility?: Record<string, boolean>; columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean; enableColumnVisibility?: boolean;
manualFiltering?: boolean;
onSearch?: (input: string) => void;
searchQuery?: string;
pagination?: DataTablePaginationState;
onPaginationChange?: DataTablePaginationUpdateFn;
persistColumnVisibility?: boolean | string; persistColumnVisibility?: boolean | string;
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
@@ -203,7 +217,12 @@ export function DataTable<TData, TValue>({
columnVisibility: defaultColumnVisibility, columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false, enableColumnVisibility = false,
persistColumnVisibility = false, persistColumnVisibility = false,
manualFiltering = false,
pagination: paginationState,
stickyLeftColumn, stickyLeftColumn,
onSearch,
searchQuery,
onPaginationChange,
stickyRightColumn stickyRightColumn
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const t = useTranslations(); const t = useTranslations();
@@ -248,22 +267,25 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility initialColumnVisibility
); );
const [pagination, setPagination] = useState<PaginationState>({ const [_pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: pageSize pageSize: pageSize
}); });
const pagination = paginationState ?? _pagination;
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || "" defaultTab || tabs?.[0]?.id || ""
); );
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>( const [activeFilters, setActiveFilters] = useState<
() => { Record<string, (string | number | boolean)[]>
const initial: Record<string, (string | number | boolean)[]> = {}; >(() => {
filters?.forEach((filter) => { const initial: Record<string, (string | number | boolean)[]> = {};
initial[filter.id] = filter.defaultValues || []; filters?.forEach((filter) => {
}); initial[filter.id] = filter.defaultValues || [];
return initial; });
} return initial;
); });
// Track initial values to avoid storing defaults on first render // Track initial values to avoid storing defaults on first render
const initialPageSize = useRef(pageSize); const initialPageSize = useRef(pageSize);
@@ -309,12 +331,18 @@ export function DataTable<TData, TValue>({
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination, onPaginationChange: onPaginationChange
? (state) => {
const newState =
typeof state === "function" ? state(pagination) : state;
onPaginationChange(newState);
}
: setPagination,
manualFiltering,
manualPagination: Boolean(paginationState),
pageCount: paginationState?.pageCount,
initialState: { initialState: {
pagination: { pagination,
pageSize: pageSize,
pageIndex: 0
},
columnVisibility: initialColumnVisibility columnVisibility: initialColumnVisibility
}, },
state: { state: {
@@ -477,12 +505,15 @@ export function DataTable<TData, TValue>({
<div className="relative w-full sm:max-w-sm"> <div className="relative w-full sm:max-w-sm">
<Input <Input
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
value={globalFilter ?? ""} defaultValue={searchQuery}
onChange={(e) => value={onSearch ? undefined : globalFilter}
table.setGlobalFilter( onChange={(e) => {
String(e.target.value) onSearch
) ? onSearch(e.currentTarget.value)
} : table.setGlobalFilter(
String(e.target.value)
);
}}
className="w-full pl-8" className="w-full pl-8"
/> />
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" /> <Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
@@ -490,12 +521,16 @@ export function DataTable<TData, TValue>({
{filters && filters.length > 0 && ( {filters && filters.length > 0 && (
<div className="flex gap-2"> <div className="flex gap-2">
{filters.map((filter) => { {filters.map((filter) => {
const selectedValues = activeFilters[filter.id] || []; const selectedValues =
const hasActiveFilters = selectedValues.length > 0; activeFilters[filter.id] || [];
const displayMode = filter.displayMode || filterDisplayMode; const hasActiveFilters =
const displayText = displayMode === "calculated" selectedValues.length > 0;
? getFilterDisplayText(filter) const displayMode =
: filter.label; filter.displayMode || filterDisplayMode;
const displayText =
displayMode === "calculated"
? getFilterDisplayText(filter)
: filter.label;
return ( return (
<DropdownMenu key={filter.id}> <DropdownMenu key={filter.id}>
@@ -507,37 +542,54 @@ export function DataTable<TData, TValue>({
> >
<Filter className="h-4 w-4 mr-2" /> <Filter className="h-4 w-4 mr-2" />
{displayText} {displayText}
{displayMode === "label" && hasActiveFilters && ( {displayMode === "label" &&
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs"> hasActiveFilters && (
{selectedValues.length} <span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
</span> {
)} selectedValues.length
}
</span>
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48"> <DropdownMenuContent
align="start"
className="w-48"
>
<DropdownMenuLabel> <DropdownMenuLabel>
{filter.label} {filter.label}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{filter.options.map((option) => { {filter.options.map(
const isChecked = selectedValues.includes(option.value); (option) => {
return ( const isChecked =
<DropdownMenuCheckboxItem selectedValues.includes(
key={option.id} option.value
checked={isChecked} );
onCheckedChange={(checked) => return (
handleFilterChange( <DropdownMenuCheckboxItem
filter.id, key={option.id}
option.value, checked={
isChecked
}
onCheckedChange={(
checked checked
) ) =>
} handleFilterChange(
onSelect={(e) => e.preventDefault()} filter.id,
> option.value,
{option.label} checked
</DropdownMenuCheckboxItem> )
); }
})} onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
@@ -795,12 +847,14 @@ export function DataTable<TData, TValue>({
</Table> </Table>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<DataTablePagination {table.getRowModel().rows?.length > 0 && (
table={table} <DataTablePagination
onPageSizeChange={handlePageSizeChange} table={table}
pageSize={pagination.pageSize} onPageSizeChange={handlePageSizeChange}
pageIndex={pagination.pageIndex} pageSize={pagination.pageSize}
/> pageIndex={pagination.pageIndex}
/>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,36 @@
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
export function useNavigationContext() {
const router = useRouter();
const searchParams = useSearchParams();
const path = usePathname();
const [isNavigating, startTransition] = useTransition();
function navigate({
searchParams: params,
pathname = path,
replace = false
}: {
pathname?: string;
searchParams?: URLSearchParams;
replace?: boolean;
}) {
startTransition(() => {
const fullPath = pathname + (params ? `?${params.toString()}` : "");
if (replace) {
router.replace(fullPath);
} else {
router.push(fullPath);
}
});
}
return {
pathname: path,
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
navigate,
isNavigating
};
}

View File

@@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null {
} }
/** /**
* Returns the full redirect target for an org: either `/${orgId}` or * Returns the full redirect target if a valid internal_redirect was stored
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the * (consumes the stored value). Returns null if none was stored or expired.
* stored value. * Paths starting with /auth/ are returned as-is; others are prefixed with orgId.
*/ */
export function getInternalRedirectTarget(orgId: string): string { export function getInternalRedirectTarget(orgId: string): string | null {
const path = consumeInternalRedirectPath(); const path = consumeInternalRedirectPath();
return path ? `/${orgId}${path}` : `/${orgId}`; if (!path) return null;
return path.startsWith("/auth/") ? path : `/${orgId}${path}`;
} }

View File

@@ -16,11 +16,16 @@ import type {
import type { ListTargetsResponse } from "@server/routers/target"; import type { ListTargetsResponse } from "@server/routers/target";
import type { ListUsersResponse } from "@server/routers/user"; import type { ListUsersResponse } from "@server/routers/user";
import type ResponseT from "@server/types/Response"; import type ResponseT from "@server/types/Response";
import { keepPreviousData, queryOptions } from "@tanstack/react-query"; import {
infiniteQueryOptions,
keepPreviousData,
queryOptions
} from "@tanstack/react-query";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import z from "zod"; import z from "zod";
import { remote } from "./api"; import { remote } from "./api";
import { durationToMs } from "./durationToMs"; import { durationToMs } from "./durationToMs";
import { wait } from "./wait";
export type ProductUpdate = { export type ProductUpdate = {
link: string | null; link: string | null;
@@ -86,8 +91,7 @@ export const productUpdatesQueries = {
}; };
export const clientFilterSchema = z.object({ export const clientFilterSchema = z.object({
filter: z.enum(["machine", "user"]), pageSize: z.int().prefault(1000).optional()
limit: z.int().prefault(1000).optional()
}); });
export const orgQueries = { export const orgQueries = {
@@ -96,14 +100,13 @@ export const orgQueries = {
filters filters
}: { }: {
orgId: string; orgId: string;
filters: z.infer<typeof clientFilterSchema>; filters?: z.infer<typeof clientFilterSchema>;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryKey: ["ORG", orgId, "CLIENTS", filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const sp = new URLSearchParams({ const sp = new URLSearchParams({
...filters, pageSize: (filters?.pageSize ?? 1000).toString()
limit: (filters.limit ?? 1000).toString()
}); });
const res = await meta!.api.get< const res = await meta!.api.get<
@@ -188,19 +191,16 @@ export const logAnalyticsFiltersSchema = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.optional(), .optional()
.catch(undefined),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string" error: "timeEnd must be a valid ISO date string"
}) })
.optional(),
resourceId: z
.string()
.optional()
.transform(Number)
.pipe(z.int().positive())
.optional() .optional()
.catch(undefined),
resourceId: z.coerce.number().optional().catch(undefined)
}); });
export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>; export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
@@ -361,22 +361,50 @@ export const approvalQueries = {
orgId: string, orgId: string,
filters: z.infer<typeof approvalFiltersSchema> filters: z.infer<typeof approvalFiltersSchema>
) => ) =>
queryOptions({ infiniteQueryOptions({
queryKey: ["APPROVALS", orgId, filters] as const, queryKey: ["APPROVALS", orgId, filters] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, pageParam, meta }) => {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (filters.approvalState) { if (filters.approvalState) {
sp.set("approvalState", filters.approvalState); sp.set("approvalState", filters.approvalState);
} }
if (pageParam) {
sp.set("cursorPending", pageParam.cursorPending.toString());
sp.set(
"cursorTimestamp",
pageParam.cursorTimestamp.toString()
);
}
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<{ approvals: ApprovalItem[] }> AxiosResponse<{
approvals: ApprovalItem[];
pagination: {
total: number;
limit: number;
cursorPending: number | null;
cursorTimestamp: number | null;
};
}>
>(`/org/${orgId}/approvals?${sp.toString()}`, { >(`/org/${orgId}/approvals?${sp.toString()}`, {
signal signal
}); });
return res.data.data; return res.data.data;
} },
initialPageParam: null as {
cursorPending: number;
cursorTimestamp: number;
} | null,
placeholderData: keepPreviousData,
getNextPageParam: ({ pagination }) =>
pagination.cursorPending != null &&
pagination.cursorTimestamp != null
? {
cursorPending: pagination.cursorPending,
cursorTimestamp: pagination.cursorTimestamp
}
: null
}), }),
pendingCount: (orgId: string) => pendingCount: (orgId: string) =>
queryOptions({ queryOptions({
@@ -388,6 +416,12 @@ export const approvalQueries = {
signal signal
}); });
return res.data.data.count; return res.data.data.count;
},
refetchInterval: (query) => {
if (query.state.data) {
return durationToMs(30, "seconds");
}
return false;
} }
}) })
}; };

52
src/lib/sortColumn.ts Normal file
View File

@@ -0,0 +1,52 @@
import type { SortOrder } from "@app/lib/types/sort";
export function getNextSortOrder(
column: string,
searchParams: URLSearchParams
) {
const sp = new URLSearchParams(searchParams);
let nextDirection: SortOrder = "indeterminate";
if (sp.get("sort_by") === column) {
nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate";
}
switch (nextDirection) {
case "indeterminate": {
nextDirection = "asc";
break;
}
case "asc": {
nextDirection = "desc";
break;
}
default: {
nextDirection = "indeterminate";
break;
}
}
sp.delete("sort_by");
sp.delete("order");
if (nextDirection !== "indeterminate") {
sp.set("sort_by", column);
sp.set("order", nextDirection);
}
return sp;
}
export function getSortDirection(
column: string,
searchParams: URLSearchParams
) {
let currentDirection: SortOrder = "indeterminate";
if (searchParams.get("sort_by") === column) {
currentDirection =
(searchParams.get("order") as SortOrder) ?? "indeterminate";
}
return currentDirection;
}

1
src/lib/types/sort.ts Normal file
View File

@@ -0,0 +1 @@
export type SortOrder = "asc" | "desc" | "indeterminate";

View File

@@ -0,0 +1,16 @@
export function validateLocalPath(value: string) {
try {
const url = new URL("https://pangoling.net" + value);
if (
url.pathname !== value ||
value.includes("..") ||
value.includes("*")
) {
throw new Error("Invalid Path");
}
} catch {
throw new Error(
"should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
);
}
}