diff --git a/.vscode/settings.json b/.vscode/settings.json index 767e57b5..5092cb6c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,7 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" diff --git a/messages/en-US.json b/messages/en-US.json index 68f9640b..3e825711 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -201,6 +201,7 @@ "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", "resourcePortNumberDescription": "The external port number to proxy requests.", + "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", "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.", "orgDeleted": "Organization 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", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", @@ -461,6 +473,8 @@ "filterByApprovalState": "Filter By Approval State", "approvalListEmpty": "No approvals", "approvalState": "Approval State", + "approvalLoadMore": "Load more", + "loadingApprovals": "Loading Approvals", "approve": "Approve", "approved": "Approved", "denied": "Denied", @@ -1169,7 +1183,8 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", - "searchProgress": "Search...", + "searchPlaceholder": "Search...", + "emptySearchOptions": "No options found", "create": "Create", "orgs": "Organizations", "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 ?", "authPageBrandingDeleteConfirm": "Confirm Delete Branding", "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", "brandingLogoWidth": "Width (px)", "brandingLogoHeight": "Height (px)", diff --git a/package-lock.json b/package-lock.json index 764d475f..cb1c84b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,6 +94,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "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": { "version": "4.8.2", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.2.tgz", diff --git a/package.json b/package.json index a9ff9a92..c8af24ba 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "tailwind-merge": "3.4.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index dc6f3758..6afd463e 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -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 { 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", { domainId: varchar("domainId").primaryKey(), @@ -188,7 +186,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), 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") }); @@ -218,7 +218,7 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").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 proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 42b2309b..7335f666 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,13 +1,6 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { - sqliteTable, - text, - integer, - index, - uniqueIndex -} from "drizzle-orm/sqlite-core"; -import { no } from "zod/v4/locales"; +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -214,7 +207,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }).default(true), hcMethod: text("hcMethod").default("GET"), 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") }); @@ -246,7 +241,7 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").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 proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts new file mode 100644 index 00000000..7295555d --- /dev/null +++ b/server/lib/deleteOrg.ts @@ -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 { + 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`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 + ); + }); + } +} diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts index c68e422a..0885c7e8 100644 --- a/server/private/routers/approvals/countApprovals.ts +++ b/server/private/routers/approvals/countApprovals.ts @@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error"; import type { Request, Response, NextFunction } from "express"; 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"; const paramsSchema = z.strictObject({ @@ -88,7 +88,7 @@ export async function countApprovals( .where( and( eq(approvals.orgId, orgId), - sql`${approvals.decision} in ${state}` + inArray(approvals.decision, state) ) ); diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts index 753a2f1a..fcac27f9 100644 --- a/server/private/routers/approvals/listApprovals.ts +++ b/server/private/routers/approvals/listApprovals.ts @@ -28,7 +28,7 @@ import { currentFingerprint, type Approval } 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 { getUserDeviceName } from "@server/db/names"; @@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({ }); const querySchema = z.strictObject({ - limit: z - .string() + limit: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - offset: z - .string() + .catch(20) + .default(20), + cursorPending: z.coerce // pending cursor + .number() + .int() + .max(1) // 0 means non pending + .min(0) // 1 means pending .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), + .catch(undefined), + cursorTimestamp: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined), approvalState: z .enum(["pending", "approved", "denied", "all"]) .optional() @@ -61,13 +69,21 @@ const querySchema = z.strictObject({ .pipe(z.number().int().positive().optional()) }); -async function queryApprovals( - orgId: string, - limit: number, - offset: number, - approvalState: z.infer["approvalState"], - clientId?: number -) { +async function queryApprovals({ + orgId, + limit, + approvalState, + cursorPending, + cursorTimestamp, + clientId +}: { + orgId: string; + limit: number; + approvalState: z.infer["approvalState"]; + cursorPending?: number; + cursorTimestamp?: number; + clientId?: number; +}) { let state: Array = []; switch (approvalState) { case "pending": @@ -83,6 +99,26 @@ async function queryApprovals( 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 .select({ approvalId: approvals.approvalId, @@ -105,7 +141,8 @@ async function queryApprovals( fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, - fingerprintHostname: currentFingerprint.hostname + fingerprintHostname: currentFingerprint.hostname, + timestamp: approvals.timestamp }) .from(approvals) .innerJoin(users, and(eq(approvals.userId, users.userId))) @@ -118,22 +155,12 @@ async function queryApprovals( ) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) - .where( - and( - eq(approvals.orgId, orgId), - 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); + .where(and(...conditions)) + .orderBy(desc(pendingSortKey), desc(approvals.timestamp)) + .limit(limit + 1); // the `+1` is used for the cursor // 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 deviceName = approval.clientName ? getUserDeviceName(model, approval.clientName) @@ -152,15 +179,15 @@ async function queryApprovals( const fingerprint = hasFingerprintData ? { - platform: approval.fingerprintPlatform || null, - osVersion: approval.fingerprintOsVersion || null, - kernelVersion: approval.fingerprintKernelVersion || null, - arch: approval.fingerprintArch || null, - deviceModel: approval.deviceModel || null, - serialNumber: approval.fingerprintSerialNumber || null, - username: approval.fingerprintUsername || null, - hostname: approval.fingerprintHostname || null - } + platform: approval.fingerprintPlatform ?? null, + osVersion: approval.fingerprintOsVersion ?? null, + kernelVersion: approval.fingerprintKernelVersion ?? null, + arch: approval.fingerprintArch ?? null, + deviceModel: approval.deviceModel ?? null, + serialNumber: approval.fingerprintSerialNumber ?? null, + username: approval.fingerprintUsername ?? null, + hostname: approval.fingerprintHostname ?? null + } : null; const { @@ -183,11 +210,30 @@ async function queryApprovals( 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 = { - approvals: NonNullable>>; - pagination: { total: number; limit: number; offset: number }; + approvals: NonNullable< + Awaited> + >["approvalsList"]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; }; 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 approvalsList = await queryApprovals( - orgId.toString(), - limit, - offset, - approvalState, - clientId - ); + const { approvalsList, nextCursorPending, nextCursorTimestamp } = + await queryApprovals({ + orgId: orgId.toString(), + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + }); const [{ count }] = await db .select({ count: sql`count(*)` }) @@ -237,7 +291,8 @@ export async function listApprovals( pagination: { total: count, limit, - offset + cursorPending: nextCursorPending, + cursorTimestamp: nextCursorTimestamp } }, success: true, diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 42254417..50248f1f 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense( next: NextFunction ): Promise { try { - - const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params); + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense( const licenseData = req.body; - if (licenseData.tier != "big_license" && licenseData.tier != "small_license") { + if ( + licenseData.tier != "big_license" && + licenseData.tier != "small_license" + ) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense( return next( createHttpError( 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 session = await stripe!.checkout.sessions.create({ @@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense( { price: tierPrice, // Use the standard tier quantity: 1 - }, + } ], // Start with the standard feature set that matches the free limits customer: customer.customerId, mode: "subscription", diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index bc93bfc0..23263654 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -26,6 +26,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, InferInsertModel } from "drizzle-orm"; import { build } from "@server/build"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; import config from "#private/lib/config"; const paramsSchema = z.strictObject({ @@ -37,14 +38,36 @@ const bodySchema = z.strictObject({ .union([ z.literal(""), z - .url("Must be a valid URL") - .superRefine(async (url, ctx) => { + .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 { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts new file mode 100644 index 00000000..2c37cd09 --- /dev/null +++ b/server/routers/auth/deleteMyAccount.ts @@ -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 { + 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(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(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(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" + ) + ); + } +} diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index ee08d155..7a469aa1 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -17,4 +17,5 @@ export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; -export * from "./lookupUser"; \ No newline at end of file +export * from "./lookupUser"; +export * from "./deleteMyAccount"; \ No newline at end of file diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 34614cc8..e195d1c5 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -6,6 +6,7 @@ export * from "./unarchiveClient"; export * from "./blockClient"; export * from "./unblockClient"; export * from "./listClients"; +export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 09560c6d..53a66150 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,34 +1,38 @@ -import { db, olms, users } from "@server/db"; import { clients, + clientSitesAssociationsCache, + currentFingerprint, + db, + olms, orgs, roleClients, sites, userClients, - clientSitesAssociationsCache, - currentFingerprint + users } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; 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, - count, + asc, + desc, eq, inArray, - isNotNull, isNull, + like, or, - sql + sql, + type 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"; -import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; 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 }); @@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({ }); const listClientsSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), - filter: z.enum(["user", "machine"]).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" + }), + 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( - 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)); - } - +function queryClientsBase() { return db .select({ clientId: clients.clientId, @@ -142,22 +194,13 @@ function queryClients( 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 + blocked: clients.blocked }) .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)) - .where(and(...conditions)); + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } async function getSiteAssociations(clientIds: number[]) { @@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } -type ClientWithSites = Awaited>[0] & { +type ClientWithSites = Awaited>[0] & { sites: Array<{ siteId: number; siteName: string | null; @@ -186,10 +229,9 @@ type ClientWithSites = Awaited>[0] & { type OlmWithUpdateAvailable = ClientWithSites; -export type ListClientsResponse = { +export type ListClientsResponse = PaginatedResponse<{ clients: Array; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ 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); if (!parsedParams.success) { @@ -267,28 +310,73 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds, filter); // Get client count with filter - const countConditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) + const conditions = [ + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) ]; - if (filter === "user") { - countConditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - countConditions.push(isNull(clients.userId)); + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); } - const countQuery = db - .select({ count: count() }) - .from(clients) - .where(and(...countConditions)); + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; - const clientsList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (status.includes("active")) { + filterAggregates.push( + 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 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 const clientsWithSites = clientsList.map((client) => { - const model = client.deviceModel || null; - let newName = client.name; - if (filter === "user") { - newName = getUserDeviceName(model, client.name); - } return { ...client, - name: newName, sites: sitesByClient[client.clientId] || [] }; }); @@ -371,8 +453,8 @@ export async function listClients( clients: olmsWithUpdates, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts new file mode 100644 index 00000000..54fffe43 --- /dev/null +++ b/server/routers/client/listUserDevices.ts @@ -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 { + try { + const cachedVersion = olmVersionCache.get("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() // 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() // 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 +>[0] & { + olmUpdateAvailable?: boolean; +}; + +export type ListUserDevicesResponse = PaginatedResponse<{ + devices: Array; +}>; + +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 { + 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`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, + 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 | 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(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") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 1a04b55e..5d25e898 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -145,6 +145,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyClientAccess, @@ -1164,6 +1171,7 @@ authRouter.post( auth.login ); authRouter.post("/logout", auth.logout); +authRouter.post("/delete-my-account", auth.deleteMyAccount); authRouter.post( "/lookup-user", rateLimit({ diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts index b9a0098b..dc7af537 100644 --- a/server/routers/idp/createIdpOrgPolicy.ts +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -70,6 +70,15 @@ export async function createIdpOrgPolicy( const { idpId, orgId } = parsedParams.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 .select() .from(idp) diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index 15728362..03626bfd 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -80,6 +80,17 @@ export async function createOidcIdp( tags } = 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 encryptedSecret = encrypt(clientSecret, key); diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts index 6432faf6..ea08de42 100644 --- a/server/routers/idp/updateIdpOrgPolicy.ts +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy( const { idpId, orgId } = parsedParams.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 const [existing] = await db .select() diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index 622d3d49..82aed75c 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -99,6 +99,15 @@ export async function updateOidcIdp( tags } = 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 const [existingIdp] = await db .select() diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 9ece5ddd..6c39fe98 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -866,6 +866,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 48d3102d..0e5b87a2 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,28 +1,12 @@ import { Request, Response, NextFunction } from "express"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { sendToClient } from "#dynamic/routers/ws"; -import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; -import { OlmErrorCodes } from "../olm/error"; -import { sendTerminateClient } from "../client/terminate"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -56,170 +40,9 @@ export async function deleteOrg( ) ); } - const { orgId } = parsedParams.data; - - const [org] = await db - .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`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 - ); - }); - } - + const result = await deleteOrgById(orgId); + sendTerminationMessages(result); return response(res, { data: null, success: true, @@ -228,6 +51,9 @@ export async function deleteOrg( status: HttpCode.OK }); } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } logger.error(error); return next( createHttpError( diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index c17e65a4..a26a5df5 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,74 +1,99 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; import { db, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility -} from "@server/db"; -import { - resources, - userResources, - roleResources, + resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, + resources, + roleResources, + targetHealthCheck, targets, - targetHealthCheck + userResources } from "@server/db"; 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 { fromZodError } from "zod-validation-error"; 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({ orgId: z.string() }); const listResourcesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .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[]) export type ResourceWithTargets = { resourceId: number; @@ -91,11 +116,32 @@ export type ResourceWithTargets = { ip: string; port: number; 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`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 + ELSE 0 + END + ) `; +const unknown_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 + ELSE 0 + END + ) `; +const unhealthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 + ELSE 0 + END + ) `; + +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, - targetId: targets.targetId, - targetIp: targets.ip, - targetPort: targets.port, - targetEnabled: targets.enabled, - - hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId }) .from(resources) .leftJoin( @@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } -export type ListResourcesResponse = { +export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ 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); if (!parsedParams.success) { @@ -252,14 +292,133 @@ export async function listResources( (resource) => resource.resourceId ); - const countQuery: any = db - .select({ count: count() }) - .from(resources) - .where(inArray(resources.resourceId, accessibleResourceIds)); + const conditions = [ + and( + 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 | 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[] const map = new Map(); @@ -288,44 +447,20 @@ export async function listResources( map.set(row.resourceId, entry); } - if ( - row.targetId != null && - 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 - }); - } + entry.targets = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); } const resourcesList: ResourceWithTargets[] = Array.from(map.values()); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0]?.count ?? 0; - return response(res, { data: { resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4fe05c26..e4881b1a 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,17 +1,25 @@ -import { db, exitNodes, newts } from "@server/db"; -import { orgs, roleSites, sites, userSites } from "@server/db"; -import { remoteExitNodes } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; +import { + db, + exitNodes, + newts, + orgs, + remoteExitNodes, + roleSites, + sites, + userSites +} from "@server/db"; +import cache from "@server/lib/cache"; 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 createHttpError from "http-errors"; +import semver from "semver"; import { z } from "zod"; 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 { try { @@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .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" + }) }); -function querySites(orgId: string, accessibleSiteIds: number[]) { +function querySitesBase() { return db .select({ siteId: sites.siteId, @@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { .leftJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) - ) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; -export type ListSitesResponse = { +export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -160,7 +203,6 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -203,34 +245,67 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds); + const { pageSize, page, query, sort_by, order, online } = + parsedQuery.data; - const countQuery = db - .select({ count: count() }) - .from(sites) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) + const accessibleSiteIds = accessibleSites.map((site) => site.siteId); + + const conditions = [ + and( + inArray(sites.siteId, accessibleSiteIds), + 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 totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const baseQuery = querySitesBase().where(and(...conditions)); + + // 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 const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( - (site) => { - const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; - // Initially set to false, will be updated if version check succeeds - siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; - } - ); + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + // Initially set to false, will be updated if version check succeeds + siteWithUpdate.newtUpdateAvailable = false; + return siteWithUpdate; + }); // Try to get the latest version, but don't block if it fails try { @@ -267,8 +342,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index b6140c27..48c298d3 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -284,7 +284,7 @@ export async function createSiteResource( niceId, orgId, name, - mode, + mode: mode as "host" | "cidr", // protocol: mode === "port" ? protocol : null, // proxyPort: mode === "port" ? proxyPort : null, // destinationPort: mode === "port" ? destinationPort : null, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index dee1eebc..ead1fc8a 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,41 +1,90 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { siteResources, sites, SiteResource } from "@server/db"; +import { db, SiteResource, siteResources, sites } from "@server/db"; 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 { 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({ orgId: z.string() }); const listAllSiteResourcesByOrgQuerySchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .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 & { siteName: string; siteNiceId: string; 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({ method: "get", @@ -80,39 +129,67 @@ export async function listAllSiteResourcesByOrg( } 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 siteResourcesList = await 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)) - .where(eq(siteResources.orgId, orgId)) - .limit(limit) - .offset(offset); + const conditions = [and(eq(siteResources.orgId, orgId))]; + if (query) { + conditions.push( + or( + like( + sql`LOWER(${siteResources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.destination})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.alias})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.aliasAddress})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } - return response(res, { - data: { siteResources: siteResourcesList }, + if (mode) { + 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(res, { + data: { + siteResources: siteResourcesList, + pagination: { + total: totalCount, + pageSize, + page + } + }, success: true, error: false, message: "Site resources retrieved successfully", diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 2bfcff19..01cbdea8 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db .update(targetHealthCheck) .set({ - hcHealth: healthStatus.status + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" }) .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); diff --git a/server/types/Pagination.ts b/server/types/Pagination.ts new file mode 100644 index 00000000..b0f5edfe --- /dev/null +++ b/server/types/Pagination.ts @@ -0,0 +1,5 @@ +export type Pagination = { total: number; pageSize: number; page: number }; + +export type PaginatedResponse = T & { + pagination: Pagination; +}; diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index b3e731e8..4b40c906 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Pagination } from "@server/types/Pagination"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let machineClients: ListClientsResponse["clients"] = []; + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { const machineRes = await internal.get< AxiosResponse >( - `/org/${params.orgId}/clients?filter=machine`, + `/org/${params.orgId}/clients?${searchParams.toString()}`, await authCookieHeader() ); - machineClients = machineRes.data.data.clients; + const responseData = machineRes.data.data; + machineClients = responseData.clients; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 7acf52b2..c0855186 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -602,7 +602,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .biometricsEnabled + .biometricsEnabled === + true ) : "-"} @@ -622,7 +623,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .diskEncrypted + .diskEncrypted === + true ) : "-"} @@ -642,7 +644,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .firewallEnabled + .firewallEnabled === + true ) : "-"} @@ -663,7 +666,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .autoUpdatesEnabled + .autoUpdatesEnabled === + true ) : "-"} @@ -683,7 +687,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .tpmAvailable + .tpmAvailable === + true ) : "-"} @@ -707,7 +712,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .windowsAntivirusEnabled + .windowsAntivirusEnabled === + true ) : "-"} @@ -727,7 +733,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosSipEnabled + .macosSipEnabled === + true ) : "-"} @@ -751,7 +758,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosGatekeeperEnabled + .macosGatekeeperEnabled === + true ) : "-"} @@ -775,7 +783,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .macosFirewallStealthMode + .macosFirewallStealthMode === + true ) : "-"} @@ -796,7 +805,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .linuxAppArmorEnabled + .linuxAppArmorEnabled === + true ) : "-"} @@ -817,7 +827,8 @@ export default function GeneralPage() { ) ? formatPostureValue( client.posture - .linuxSELinuxEnabled + .linuxSELinuxEnabled === + true ) : "-"} diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 35a2b2e3..fcb24e4e 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -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 { ListClientsResponse } from "@server/routers/client"; -import { getTranslations } from "next-intl/server"; import type { ClientRow } 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 = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); 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 { - const userRes = await internal.get>( - `/org/${params.orgId}/clients?filter=user`, + const userRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, await authCookieHeader() ); - userClients = userRes.data.data.clients; + const responseData = userRes.data.data; + userClients = responseData.devices; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) { } const mapClientToRow = ( - client: ListClientsResponse["clients"][0] + client: ListUserDevicesResponse["devices"][number] ): ClientRow => { // Build fingerprint object if any fingerprint data exists const hasFingerprintData = - (client as any).fingerprintPlatform || - (client as any).fingerprintOsVersion || - (client as any).fingerprintKernelVersion || - (client as any).fingerprintArch || - (client as any).fingerprintSerialNumber || - (client as any).fingerprintUsername || - (client as any).fingerprintHostname || - (client as any).deviceModel; + client.fingerprintPlatform || + client.fingerprintOsVersion || + client.fingerprintKernelVersion || + client.fingerprintArch || + client.fingerprintSerialNumber || + client.fingerprintUsername || + client.fingerprintHostname || + client.deviceModel; const fingerprint = hasFingerprintData ? { - platform: (client as any).fingerprintPlatform || null, - osVersion: (client as any).fingerprintOsVersion || null, - kernelVersion: - (client as any).fingerprintKernelVersion || null, - arch: (client as any).fingerprintArch || null, - deviceModel: (client as any).deviceModel || null, - serialNumber: - (client as any).fingerprintSerialNumber || null, - username: (client as any).fingerprintUsername || null, - hostname: (client as any).fingerprintHostname || null + platform: client.fingerprintPlatform, + osVersion: client.fingerprintOsVersion, + kernelVersion: client.fingerprintKernelVersion, + arch: client.fingerprintArch, + deviceModel: client.deviceModel, + serialNumber: client.fingerprintSerialNumber, + username: client.fingerprintUsername, + hostname: client.fingerprintHostname } : null; @@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], - mbIn: formatSize(client.megabytesIn || 0), - mbOut: formatSize(client.megabytesOut || 0), + mbIn: formatSize(client.megabytesIn ?? 0), + mbOut: formatSize(client.megabytesOut ?? 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, + olmUpdateAvailable: Boolean(client.olmUpdateAvailable), userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, - archived: client.archived || false, - blocked: client.blocked || false, + archived: Boolean(client.archived), + blocked: Boolean(client.blocked), approvalState: client.approvalState, fingerprint }; @@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index ac85520e..f5e1a701 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -14,7 +14,7 @@ import { redirect } from "next/navigation"; export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ClientResourcesPage( @@ -22,22 +22,24 @@ export default async function ClientResourcesPage( ) { const params = await props.params; const t = await getTranslations(); - - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) {} + const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get< AxiosResponse - >(`/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) {} let org = null; @@ -89,9 +91,10 @@ export default async function ClientResourcesPage( diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index 408a9352..57505c53 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -16,7 +16,7 @@ import { cache } from "react"; export interface ProxyResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ProxyResourcesPage( @@ -24,14 +24,22 @@ export default async function ProxyResourcesPage( ) { const params = await props.params; const t = await getTranslations(); + const searchParams = new URLSearchParams(await props.searchParams); let resources: ListResourcesResponse["resources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/resources`, + `/org/${params.orgId}/resources?${searchParams.toString()}`, await authCookieHeader() ); - resources = res.data.data.resources; + const responseData = res.data.data; + resources = responseData.resources; + pagination = responseData.pagination; } catch (e) {} let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; @@ -104,9 +112,10 @@ export default async function ProxyResourcesPage( diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 85f0e2b1..161c757f 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server"; type SitesPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function SitesPage(props: SitesPageProps) { const params = await props.params; + + const searchParams = new URLSearchParams(await props.searchParams); + let sites: ListSitesResponse["sites"] = []; + let pagination: ListSitesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get>( - `/org/${params.orgId}/sites`, + `/org/${params.orgId}/sites?${searchParams.toString()}`, await authCookieHeader() ); - sites = res.data.data.sites; + const responseData = res.data.data; + sites = responseData.sites; + pagination = responseData.pagination; } catch (e) {} const t = await getTranslations(); @@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) { return ( <> - {/* */} - - + ); } diff --git a/src/app/auth/delete-account/DeleteAccountClient.tsx b/src/app/auth/delete-account/DeleteAccountClient.tsx new file mode 100644 index 00000000..8cd150af --- /dev/null +++ b/src/app/auth/delete-account/DeleteAccountClient.tsx @@ -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 ( +
+ +

+ {t("deleteAccountDescription")} +

+
+ + +
+ +
+ ); +} diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx new file mode 100644 index 00000000..5cbc8d73 --- /dev/null +++ b/src/app/auth/delete-account/page.tsx @@ -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 ( +
+

{t("deleteAccount")}

+ +
+ ); +} diff --git a/src/components/ApplyInternalRedirect.tsx b/src/components/ApplyInternalRedirect.tsx index f2afc8cb..24e93336 100644 --- a/src/components/ApplyInternalRedirect.tsx +++ b/src/components/ApplyInternalRedirect.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { consumeInternalRedirectPath } from "@app/lib/internalRedirect"; +import { getInternalRedirectTarget } from "@app/lib/internalRedirect"; type ApplyInternalRedirectProps = { orgId: string; @@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({ const router = useRouter(); useEffect(() => { - const path = consumeInternalRedirectPath(); - if (path) { - router.replace(`/${orgId}${path}`); + const target = getInternalRedirectTarget(orgId); + if (target) { + router.replace(target); } }, [orgId, router]); diff --git a/src/components/ApprovalFeed.tsx b/src/components/ApprovalFeed.tsx index 87a9d11a..14465fe3 100644 --- a/src/components/ApprovalFeed.tsx +++ b/src/components/ApprovalFeed.tsx @@ -2,16 +2,16 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cn } from "@app/lib/cn"; import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { approvalFiltersSchema, approvalQueries, type ApprovalItem } from "@app/lib/queries"; -import { useQuery } from "@tanstack/react-query"; -import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Ban, Check, Loader, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -54,12 +54,20 @@ export function ApprovalFeed({ const { isPaidUser } = usePaidStatus(); - const { data, isFetching, refetch } = useQuery({ + const { + data, + isFetching, + isLoading, + refetch, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery({ ...approvalQueries.listApprovals(orgId, filters), 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 if (!hasApprovalsEnabled) { @@ -115,13 +123,13 @@ export function ApprovalFeed({ onClick={() => { refetch(); }} - disabled={isFetching} + disabled={isFetching || isLoading} className="lg:static gap-2" > {t("refresh")} @@ -145,13 +153,30 @@ export function ApprovalFeed({ ))} {approvals.length === 0 && ( -
  • - {t("approvalListEmpty")} +
  • + {isLoading + ? t("loadingApprovals") + : t("approvalListEmpty")} + + {isLoading && ( + + )}
  • )} + {hasNextPage && ( + + )} ); } diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index a1998062..ca49a50a 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -1,9 +1,5 @@ "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 { Form, FormControl, @@ -13,6 +9,11 @@ import { FormLabel, FormMessage } 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 { SettingsSection, SettingsSectionBody, @@ -21,19 +22,19 @@ import { SettingsSectionHeader, SettingsSectionTitle } 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 { useRouter } from "next/navigation"; -import { toast } from "@app/hooks/useToast"; 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 type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types"; +import { XIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; 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 { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -45,13 +46,36 @@ export type AuthPageCustomizationProps = { const AuthPageFormSchema = z.object({ logoUrl: z.union([ 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 { - const response = await fetch(url, { + const response = await fetch(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET - return fetch(url, { method: "GET" }); + return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { @@ -271,12 +295,25 @@ export default function AuthPageBrandingForm({ render={({ field }) => ( - {t("brandingLogoURL")} + {build === "enterprise" + ? t( + "brandingLogoURLOrPath" + ) + : t("brandingLogoURL")} + + {build === "enterprise" + ? t( + "brandingLogoPathDescription" + ) + : t( + "brandingLogoURLDescription" + )} + )} /> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index c49cde8d..126eb242 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { orgQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; +import type { PaginationState } from "@tanstack/react-table"; +import { ControlledDataTable } from "./ui/controlled-data-table"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useDebouncedCallback } from "use-debounce"; +import { ColumnFilterButton } from "./ColumnFilterButton"; export type InternalResourceRow = { id: number; @@ -51,18 +56,22 @@ export type InternalResourceRow = { type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ClientResourcesTable({ internalResources, orgId, - defaultSort + pagination, + rowCount }: ClientResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -122,19 +131,7 @@ export default function ClientResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", @@ -180,9 +177,24 @@ export default function ClientResourcesTable({ accessorKey: "mode", friendlyName: t("editInternalResourceDialogMode"), header: () => ( - - {t("editInternalResourceDialogMode")} - + handleFilterChange("mode", value)} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("editInternalResourceDialogMode")} + className="p-3" + /> ), cell: ({ row }) => { 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 ( <> {selectedInternalResource && ( @@ -327,19 +370,20 @@ export default function ClientResourcesTable({ /> )} - setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} + onSearch={handleSearchChange} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="internal-resources" + onPaginationChange={handlePaginationChange} + pagination={pagination} + rowCount={rowCount} + isRefreshing={isRefreshing || isFiltering} + enableColumnVisibility columnVisibility={{ niceId: false, aliasAddress: false diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index a856984e..3e7b585b 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { Badge } from "./ui/badge"; interface FilterOption { value: string; @@ -61,16 +62,19 @@ export function ColumnFilter({ >
    - - {selectedOption - ? selectedOption.label - : placeholder} - + + {selectedOption && ( + + {selectedOption + ? selectedOption.label + : placeholder} + + )}
    - + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx new file mode 100644 index 00000000..7d17066c --- /dev/null +++ b/src/components/ColumnFilterButton.tsx @@ -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 ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value + ? undefined + : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 34e8f55e..25e5a721 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -255,10 +255,7 @@ export default function CreateInternalResourceDialog({ const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); const { data: clientsResponse = [] } = useQuery( orgQueries.clients({ - orgId, - filters: { - filter: "machine" - } + orgId }) ); diff --git a/src/components/DeleteAccountConfirmDialog.tsx b/src/components/DeleteAccountConfirmDialog.tsx new file mode 100644 index 00000000..7a54f9a0 --- /dev/null +++ b/src/components/DeleteAccountConfirmDialog.tsx @@ -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(null); + const [passwordValue, setPasswordValue] = useState(""); + + const passwordForm = useForm>({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "" } + }); + + const codeForm = useForm>({ + resolver: zodResolver(codeSchema), + defaultValues: { code: "" } + }); + + useEffect(() => { + if (open && step === 0 && !preview) { + setLoadingPreview(true); + api.post>( + "/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 + ) { + setLoading(true); + setPasswordValue(values.password); + try { + const res = await api.post< + | AxiosResponse + | AxiosResponse + >("/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) { + setLoading(true); + try { + const res = await api.post< + AxiosResponse + >("/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 ( + { + setOpen(val); + if (!val) reset(); + }} + > + + + + {t("deleteAccountConfirmTitle")} + + + +
    + {step === 0 && ( + <> + {loadingPreview ? ( +

    + {t("loading")}... +

    + ) : preview ? ( + <> +

    + {t("deleteAccountConfirmMessage")} +

    +
    +

    + {t( + "deleteAccountPreviewAccount" + )} +

    + {preview.orgs.length > 0 && ( + <> +

    + {t( + "deleteAccountPreviewOrgs" + )} +

    +
      + {preview.orgs.map( + (org) => ( +
    • + {org.name || + org.orgId} +
    • + ) + )} +
    + + )} +
    +

    + {t("cannotbeUndone")} +

    + + ) : null} + + )} + + {step === 1 && ( +
    + + ( + + + {t("password")} + + + + + + + )} + /> + + + )} + + {step === 2 && ( +
    +
    +

    + {t("otpAuthDescription")} +

    +
    +
    + + ( + + +
    + { + field.onChange( + value + ); + }} + > + + + + + + + + + +
    +
    + +
    + )} + /> + + +
    + )} +
    +
    + + + + + {step === 0 && preview && !loadingPreview && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + +
    +
    + ); +} diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 4c1176e5..d6078052 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({ orgQueries.roles({ orgId }), orgQueries.users({ orgId }), orgQueries.clients({ - orgId, - filters: { - filter: "machine" - } + orgId }), resourceQueries.siteResourceUsers({ siteResourceId: resource.id }), resourceQueries.siteResourceRoles({ siteResourceId: resource.id }), diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index ad01c40f..97de4113 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -16,13 +16,23 @@ import { ArrowRight, ArrowUpDown, MoreHorizontal, - CircleSlash + CircleSlash, + ArrowDown01Icon, + ArrowUp10Icon, + ChevronsUpDownIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; 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 = { id: number; @@ -48,14 +58,24 @@ export type ClientRow = { type ClientTableProps = { machineClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; export default function MachineClientsTable({ machineClients, - orgId + orgId, + pagination, + rowCount }: ClientTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); + const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -65,6 +85,7 @@ export default function MachineClientsTable({ const api = createApiClient(useEnvContext()); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const defaultMachineColumnVisibility = { subnet: false, @@ -182,22 +203,8 @@ export default function MachineClientsTable({ { accessorKey: "name", enableHiding: false, - friendlyName: "Name", - header: ({ column }) => { - return ( - - ); - }, + friendlyName: t("name"), + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; return ( @@ -224,38 +231,35 @@ export default function MachineClientsTable({ { accessorKey: "niceId", friendlyName: "Identifier", - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "online", - friendlyName: "Connectivity", - header: ({ column }) => { + friendlyName: t("online"), + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -279,38 +283,52 @@ export default function MachineClientsTable({ }, { accessorKey: "mbIn", - friendlyName: "Data In", - header: ({ column }) => { + friendlyName: t("dataIn"), + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } }, { accessorKey: "mbOut", - friendlyName: "Data Out", - header: ({ column }) => { + friendlyName: t("dataOut"), + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -318,21 +336,7 @@ export default function MachineClientsTable({ { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("agent")}, cell: ({ row }) => { const originalRow = row.original; @@ -356,22 +360,8 @@ export default function MachineClientsTable({ }, { accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } + friendlyName: t("address"), + header: () => {t("address")} } ]; @@ -455,7 +445,56 @@ export default function MachineClientsTable({ } 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 ( <> @@ -478,20 +517,25 @@ export default function MachineClientsTable({ title="Delete Client" /> )} - - router.push(`/${orgId}/settings/clients/machine/create`) + startNavigation(() => + router.push(`/${orgId}/settings/clients/machine/create`) + ) } + pagination={pagination} + rowCount={rowCount} addButtonText={t("createClient")} onRefresh={refreshData} - isRefreshing={isRefreshing} - enableColumnVisibility={true} - persistColumnVisibility="machine-clients" + isRefreshing={isRefreshing || isFiltering} + onSearch={handleSearchChange} + onPaginationChange={handlePaginationChange} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={defaultMachineColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" @@ -518,30 +562,10 @@ export default function MachineClientsTable({ value: "blocked" } ], - filterFn: ( - row: ClientRow, - 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; + onValueChange(selectedValues: string[]) { + handleFilterChange("status", selectedValues); }, - defaultValues: ["active"] // Default to showing active clients + values: searchParams.getAll("status") } ]} /> diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b2939a90..e139e43a 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -83,7 +83,7 @@ export function OrgSelector({ diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index d466b707..4c900c62 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; 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 { useRouter } from "next/navigation"; +import Link from "next/link"; +import { build } from "@server/build"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; @@ -187,6 +189,20 @@ export default function ProfileIcon() { + {user?.type === UserType.Internal && !user?.serverAdmin && ( + <> + + + + {t("deleteAccount")} + + + + + )} logout()}> {/* */} {t("logout")} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 69b180c4..490904c7 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -2,9 +2,8 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; 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 { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -14,13 +13,14 @@ import { import { InfoPopup } from "@app/components/ui/info-popup"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; +import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ArrowRight, - ArrowUpDown, CheckCircle2, ChevronDown, Clock, @@ -32,14 +32,24 @@ import { import { useTranslations } from "next-intl"; import Link from "next/link"; 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 = { targetId: number; ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }; export type ResourceRow = { @@ -117,18 +127,22 @@ function StatusIcon({ type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; - defaultSort?: { - id: string; - desc: boolean; - }; + pagination: PaginationState; + rowCount: number; }; export default function ProxyResourcesTable({ resources, orgId, - defaultSort + pagination, + rowCount }: ProxyResourcesTableProps) { const router = useRouter(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); @@ -140,6 +154,7 @@ export default function ProxyResourcesTable({ useState(); const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const refreshData = () => { startTransition(() => { @@ -174,23 +189,24 @@ export default function ProxyResourcesTable({ }; async function toggleResourceEnabled(val: boolean, resourceId: number) { - await api - .post>( + try { + await api.post>( `resource/${resourceId}`, { enabled: val } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourcesErrorUpdate"), - description: formatAxiosError( - e, - t("resourcesErrorUpdateDescription") - ) - }); + ); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) }); + } } function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { @@ -236,7 +252,7 @@ export default function ProxyResourcesTable({ - + {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => ( @@ -302,38 +318,14 @@ export default function ProxyResourcesTable({ accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("name")} }, { id: "niceId", accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("identifier")}, cell: ({ row }) => { return {row.original.nice || "-"}; } @@ -359,19 +351,33 @@ export default function ProxyResourcesTable({ id: "status", accessorKey: "status", friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("healthStatus", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("status")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ; @@ -419,19 +425,23 @@ export default function ProxyResourcesTable({ { accessorKey: "authState", friendlyName: t("authentication"), - header: ({ column }) => { - return ( - - ); - }, + header: () => ( + + handleFilterChange("authState", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("authentication")} + className="p-3" + /> + ), cell: ({ row }) => { const resourceRow = row.original; return ( @@ -456,20 +466,28 @@ export default function ProxyResourcesTable({ { accessorKey: "enabled", friendlyName: t("enabled"), - header: () => {t("enabled")}, + header: () => ( + + handleFilterChange("enabled", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("enabled")} + className="p-3" + /> + ), cell: ({ row }) => ( - - 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 ( <> {selectedResource && ( @@ -547,21 +601,25 @@ export default function ProxyResourcesTable({ /> )} - - router.push(`/${orgId}/settings/resources/proxy/create`) + startNavigation(() => + router.push(`/${orgId}/settings/resources/proxy/create`) + ) } addButtonText={t("resourceAdd")} onRefresh={refreshData} - isRefreshing={isRefreshing} - defaultSort={defaultSort} - enableColumnVisibility={true} - persistColumnVisibility="proxy-resources" + isRefreshing={isRefreshing || isFiltering} + isNavigatingToAddPage={isNavigatingToAddPage} + enableColumnVisibility columnVisibility={{ niceId: false }} stickyLeftColumn="name" stickyRightColumn="actions" @@ -569,3 +627,43 @@ export default function ProxyResourcesTable({ ); } + +type ResourceEnabledFormProps = { + resource: ResourceRow; + onToggleResourceEnabled: ( + val: boolean, + resourceId: number + ) => Promise; +}; + +function ResourceEnabledForm({ + resource, + onToggleResourceEnabled +}: ResourceEnabledFormProps) { + const enabled = resource.http + ? !!resource.domainId && resource.enabled + : resource.enabled; + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); + + const formRef = useRef>(null); + + async function submitAction(formData: FormData) { + const newEnabled = !(formData.get("enabled") === "on"); + setOptimisticEnabled(newEnabled); + await onToggleResourceEnabled(newEnabled, resource.id); + } + + return ( +
    + formRef.current?.requestSubmit()} + /> + + ); +} diff --git a/src/components/RedirectToOrg.tsx b/src/components/RedirectToOrg.tsx index 7ea1ea4b..e647ee7a 100644 --- a/src/components/RedirectToOrg.tsx +++ b/src/components/RedirectToOrg.tsx @@ -13,7 +13,8 @@ export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) { useEffect(() => { try { - const target = getInternalRedirectTarget(targetOrgId); + const target = + getInternalRedirectTarget(targetOrgId) ?? `/${targetOrgId}`; router.replace(target); } catch { router.replace(`/${targetOrgId}`); diff --git a/src/components/SitesDataTable.tsx b/src/components/SitesDataTable.tsx deleted file mode 100644 index 125f4d59..00000000 --- a/src/components/SitesDataTable.tsx +++ /dev/null @@ -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 { - columns: ColumnDef[]; - data: TData[]; - createSite?: () => void; - onRefresh?: () => void; - isRefreshing?: boolean; - columnVisibility?: Record; - enableColumnVisibility?: boolean; -} - -export function SitesDataTable({ - columns, - data, - createSite, - onRefresh, - isRefreshing, - columnVisibility, - enableColumnVisibility -}: DataTableProps) { - const t = useTranslations(); - - return ( - - ); -} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d8f50e99..c7857773 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -1,37 +1,42 @@ "use client"; -import { Column, ColumnDef } from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { SitesDataTable } from "@app/components/SitesDataTable"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; + +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } 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 { 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 { 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 = { id: number; @@ -52,79 +57,91 @@ export type SiteRow = { type SitesTableProps = { sites: SiteRow[]; + pagination: PaginationState; orgId: string; + rowCount: number; }; -export default function SitesTable({ sites, orgId }: SitesTableProps) { +export default function SitesTable({ + sites, + orgId, + pagination, + rowCount +}: SitesTableProps) { const router = useRouter(); + const pathname = usePathname(); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); - const [rows, setRows] = useState(sites); - const [isRefreshing, setIsRefreshing] = useState(false); + const [isRefreshing, startTransition] = useTransition(); + const [isNavigatingToAddPage, startNavigation] = useTransition(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); - // Update local state when props change (e.g., after refresh) - useEffect(() => { - setRows(sites); - }, [sites]); + const booleanSearchFilterSchema = z + .enum(["true", "false"]) + .optional() + .catch(undefined); - const refreshData = async () => { - console.log("Data refreshed"); - setIsRefreshing(true); - try { - await new Promise((resolve) => setTimeout(resolve, 200)); - router.refresh(); - } catch (error) { - toast({ - title: t("error"), - description: t("refreshError"), - variant: "destructive" - }); - } finally { - setIsRefreshing(false); + function handleFilterChange( + column: string, + value: string | undefined | null + ) { + const sp = new URLSearchParams(searchParams); + sp.delete(column); + sp.delete("page"); + + if (value) { + sp.set(column, value); } - }; + startTransition(() => router.push(`${pathname}?${sp.toString()}`)); + } - const deleteSite = (siteId: number) => { - api.delete(`/site/${siteId}`) - .catch((e) => { - console.error(t("siteErrorDelete"), e); - toast({ - variant: "destructive", - title: t("siteErrorDelete"), - description: formatAxiosError(e, t("siteErrorDelete")) - }); - }) - .then(() => { + function refreshData() { + startTransition(async () => { + try { router.refresh(); - setIsDeleteModalOpen(false); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }); + } - const newRows = rows.filter((row) => row.id !== siteId); - - setRows(newRows); - }); - }; + function deleteSite(siteId: number) { + startTransition(async () => { + 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[] = [ { accessorKey: "name", enableHiding: false, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("name")}; } }, { @@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("identifier")}; }, cell: ({ row }) => { return {row.original.nice || "-"}; @@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "online", friendlyName: t("online"), - header: ({ column }) => { + header: () => { return ( - + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -194,58 +208,59 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - - parseDataSize(rowB.original.mbIn) + } }, { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); - }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - - parseDataSize(rowB.original.mbOut) + } }, { accessorKey: "type", friendlyName: t("type"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("type")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -290,18 +305,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { { accessorKey: "exitNode", friendlyName: t("exitNode"), - header: ({ column }) => { - return ( - - ); + header: () => { + return {t("exitNode")}; }, cell: ({ row }) => { const originalRow = row.original; @@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }, { accessorKey: "address", - header: ({ column }: { column: Column }) => { - return ( - - ); + header: () => { + return {t("address")}; }, cell: ({ row }: { row: any }) => { 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 ( <> {selectedSite && ( @@ -444,27 +463,42 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { } buttonText={t("siteConfirmDelete")} - onConfirm={async () => deleteSite(selectedSite!.id)} + onConfirm={async () => + startTransition(() => deleteSite(selectedSite!.id)) + } string={selectedSite.name} title={t("siteDelete")} /> )} - - router.push(`/${orgId}/settings/sites/create`) + rows={sites} + tableId="sites-table" + 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} - isRefreshing={isRefreshing} + isRefreshing={isRefreshing || isFiltering} + rowCount={rowCount} columnVisibility={{ niceId: false, nice: false, exitNode: false, address: false }} - enableColumnVisibility={true} + enableColumnVisibility + stickyLeftColumn="name" + stickyRightColumn="actions" /> ); diff --git a/src/components/TanstackQueryProvider.tsx b/src/components/TanstackQueryProvider.tsx index 9a6e7dd9..ab469c2b 100644 --- a/src/components/TanstackQueryProvider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -1,11 +1,13 @@ "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 { 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 = { children: React.ReactNode; @@ -22,7 +24,8 @@ export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { staleTime: 0, meta: { api - } + }, + placeholderData: keepPreviousData }, mutations: { meta: { api } diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 0a1cf287..1b7b0c69 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -2,34 +2,41 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; +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 { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint"; 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 { + ArrowDown01Icon, ArrowRight, - ArrowUpDown, + ArrowUp10Icon, ArrowUpRight, - MoreHorizontal, - CircleSlash + ChevronsUpDownIcon, + CircleSlash, + MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; +import { useDebouncedCallback } from "use-debounce"; import ClientDownloadBanner from "./ClientDownloadBanner"; +import { ColumnFilterButton } from "./ColumnFilterButton"; import { Badge } from "./ui/badge"; -import { build } from "@server/build"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { InfoPopup } from "@app/components/ui/info-popup"; +import { ControlledDataTable } from "./ui/controlled-data-table"; export type ClientRow = { id: number; @@ -65,9 +72,15 @@ export type ClientRow = { type ClientTableProps = { userClients: ClientRow[]; orgId: string; + pagination: PaginationState; + rowCount: number; }; -export default function UserDevicesTable({ userClients }: ClientTableProps) { +export default function UserDevicesTable({ + userClients, + pagination, + rowCount +}: ClientTableProps) { const router = useRouter(); const t = useTranslations(); @@ -77,6 +90,11 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); const api = createApiClient(useEnvContext()); + const { + navigate: filter, + isNavigating: isFiltering, + searchParams + } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); const defaultUserColumnVisibility = { @@ -188,8 +206,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); const approval = approvalsRes.data.data.approvals[0]; @@ -202,9 +224,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "approved" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "approved" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -230,8 +255,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { try { // Fetch approvalId for this client using clientId query parameter const approvalsRes = await api.get<{ - data: { approvals: Array<{ approvalId: number; clientId: number }> }; - }>(`/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}`); + data: { + approvals: Array<{ approvalId: number; clientId: number }>; + }; + }>( + `/org/${clientRow.orgId}/approvals?approvalState=pending&clientId=${clientRow.id}` + ); const approval = approvalsRes.data.data.approvals[0]; @@ -244,9 +273,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return; } - await api.put(`/org/${clientRow.orgId}/approvals/${approval.approvalId}`, { - decision: "denied" - }); + await api.put( + `/org/${clientRow.orgId}/approvals/${approval.approvalId}`, + { + decision: "denied" + } + ); toast({ title: t("accessApprovalUpdated"), @@ -279,21 +311,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { accessorKey: "name", enableHiding: false, friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("name")}, cell: ({ row }) => { const r = row.original; const fingerprintInfo = r.fingerprint @@ -343,40 +361,12 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "niceId", friendlyName: t("identifier"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("identifier")} }, { accessorKey: "userEmail", friendlyName: t("users"), - header: ({ column }) => { - return ( - - ); - }, + header: () => {t("users")}, cell: ({ row }) => { const r = row.original; return r.userId ? ( @@ -398,20 +388,31 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }, { accessorKey: "online", - friendlyName: t("connectivity"), - header: ({ column }) => { + friendlyName: t("online"), + header: () => { return ( - + onValueChange={(value) => + handleFilterChange("online", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("online")} + className="p-3" + /> ); }, cell: ({ row }) => { @@ -436,18 +437,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbIn", friendlyName: t("dataIn"), - header: ({ column }) => { + header: () => { + const dataInOrder = getSortDirection( + "megabytesIn", + searchParams + ); + + const Icon = + dataInOrder === "asc" + ? ArrowDown01Icon + : dataInOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -455,18 +463,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "mbOut", friendlyName: t("dataOut"), - header: ({ column }) => { + header: () => { + const dataOutOrder = getSortDirection( + "megabytesOut", + searchParams + ); + + const Icon = + dataOutOrder === "asc" + ? ArrowDown01Icon + : dataOutOrder === "desc" + ? ArrowUp10Icon + : ChevronsUpDownIcon; return ( ); } @@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "client", friendlyName: t("agent"), - header: ({ column }) => { - return ( - - ); - }, + ]} + selectedValue={searchParams.get("agent") ?? undefined} + onValueChange={(value) => + handleFilterChange("agent", value) + } + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} + label={t("agent")} + className="p-3" + /> + ), cell: ({ row }) => { const originalRow = row.original; @@ -514,21 +560,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { { accessorKey: "subnet", friendlyName: t("address"), - header: ({ column }) => { - return ( - - ); - } + header: () => {t("address")} } ]; @@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { - {clientRow.approvalState === "pending" && ( - <> - approveDevice(clientRow)} - > - {t("approve")} - - denyDevice(clientRow)} - > - {t("deny")} - - - )} + {clientRow.approvalState === "pending" && + build !== "oss" && ( + <> + + approveDevice(clientRow) + } + > + {t("approve")} + + + denyDevice(clientRow) + } + > + {t("deny")} + + + )} { if (clientRow.archived) { @@ -621,7 +658,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); return baseColumns; - }, [hasRowsWithoutUserId, t]); + }, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]); const statusFilterOptions = useMemo(() => { 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; }, [t]); - const statusFilterDefaultValues = useMemo(() => { - return ["active", "pending"]; - }, []); + 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 ( <> @@ -682,17 +766,19 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { )} - { - 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; + onValueChange: (selectedValues: string[]) => { + handleFilterChange("status", selectedValues); }, - defaultValues: statusFilterDefaultValues + values: searchParams.getAll("status") } ]} /> diff --git a/src/components/resource-target-address-item.tsx b/src/components/resource-target-address-item.tsx index 3c4cb927..6479ede7 100644 --- a/src/components/resource-target-address-item.tsx +++ b/src/components/resource-target-address-item.tsx @@ -20,6 +20,7 @@ import { import { Input } from "./ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; +import { useEffect } from "react"; type SiteWithUpdateAvailable = ListSitesResponse["sites"][number]; diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx new file mode 100644 index 00000000..a0231bb8 --- /dev/null +++ b/src/components/ui/controlled-data-table.tsx @@ -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 = 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 = { + columns: ExtendedColumnDef[]; + 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; + 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({ + 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) { + const t = useTranslations(); + + const [columnFilters, setColumnFilters] = useState([]); + + const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility( + tableId, + defaultColumnVisibility + ); + + // TODO: filters + const activeFilters = useMemo(() => { + const initial: Record = {}; + 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 ( +
    + + +
    + {onSearch && ( +
    + + onSearch(e.currentTarget.value) + } + className="w-full pl-8" + /> + +
    + )} + + {filters && filters.length > 0 && ( +
    + {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 ( + + + + + + + {filter.label} + + + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + { + handleFilterChange( + filter.id, + option.value, + checked + ); + }} + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} + + + ); + })} +
    + )} +
    +
    + {onRefresh && ( +
    + +
    + )} + {onAdd && addButtonText && ( +
    + +
    + )} +
    +
    + +
    + + + {table.getHeaderGroups().map((headerGroup) => ( + + {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 ( + + {header.isPlaceholder ? null : isRightSticky && + hasHideableColumns ? ( +
    + + + + + + + {t( + "toggleColumns" + ) || + "Toggle columns"} + + + {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 ( + + column.toggleVisibility( + !!value + ) + } + onSelect={( + e + ) => + e.preventDefault() + } + > + { + displayName + } + + ); + } + )} + + +
    + {flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} +
    +
    + ) : ( + flexRender( + header.column + .columnDef + .header, + header.getContext() + ) + )} +
    + ); + })} +
    + ))} +
    + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {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 ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + + )) + ) : ( + + + No results found. + + + )} + +
    +
    +
    + {rowCount > 0 && ( + + onPaginationChange({ + ...pagination, + pageSize + }) + } + onPageChange={(pageIndex) => { + onPaginationChange({ + ...pagination, + pageIndex + }); + }} + isServerPagination + pageSize={pagination.pageSize} + pageIndex={pagination.pageIndex} + /> + )} +
    +
    +
    +
    + ); +} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index af61bb53..834c56e8 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -151,11 +151,20 @@ type DataTableFilter = { label: string; options: FilterOption[]; multiSelect?: boolean; - filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + filterFn: ( + row: any, + selectedValues: (string | number | boolean)[] + ) => boolean; defaultValues?: (string | number | boolean)[]; displayMode?: "label" | "calculated"; // How to display the filter button text }; +export type DataTablePaginationState = PaginationState & { + pageCount: number; +}; + +export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -178,6 +187,11 @@ type DataTableProps = { defaultPageSize?: number; columnVisibility?: Record; enableColumnVisibility?: boolean; + manualFiltering?: boolean; + onSearch?: (input: string) => void; + searchQuery?: string; + pagination?: DataTablePaginationState; + onPaginationChange?: DataTablePaginationUpdateFn; persistColumnVisibility?: boolean | string; stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions") @@ -203,7 +217,12 @@ export function DataTable({ columnVisibility: defaultColumnVisibility, enableColumnVisibility = false, persistColumnVisibility = false, + manualFiltering = false, + pagination: paginationState, stickyLeftColumn, + onSearch, + searchQuery, + onPaginationChange, stickyRightColumn }: DataTableProps) { const t = useTranslations(); @@ -248,22 +267,25 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); - const [pagination, setPagination] = useState({ + const [_pagination, setPagination] = useState({ pageIndex: 0, pageSize: pageSize }); + + const pagination = paginationState ?? _pagination; + const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); - const [activeFilters, setActiveFilters] = useState>( - () => { - const initial: Record = {}; - filters?.forEach((filter) => { - initial[filter.id] = filter.defaultValues || []; - }); - return initial; - } - ); + const [activeFilters, setActiveFilters] = useState< + Record + >(() => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.defaultValues || []; + }); + return initial; + }); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -309,12 +331,18 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, 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: { - pagination: { - pageSize: pageSize, - pageIndex: 0 - }, + pagination, columnVisibility: initialColumnVisibility }, state: { @@ -368,11 +396,11 @@ export function DataTable({ setActiveFilters((prev) => { const currentValues = prev[filterId] || []; const filter = filters?.find((f) => f.id === filterId); - + if (!filter) return prev; let newValues: (string | number | boolean)[]; - + if (filter.multiSelect) { // Multi-select: add or remove the value if (checked) { @@ -397,7 +425,7 @@ export function DataTable({ // 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; } @@ -477,12 +505,15 @@ export function DataTable({
    - table.setGlobalFilter( - String(e.target.value) - ) - } + defaultValue={searchQuery} + value={onSearch ? undefined : globalFilter} + onChange={(e) => { + onSearch + ? onSearch(e.currentTarget.value) + : table.setGlobalFilter( + String(e.target.value) + ); + }} className="w-full pl-8" /> @@ -490,13 +521,17 @@ export function DataTable({ {filters && filters.length > 0 && (
    {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; - + const selectedValues = + activeFilters[filter.id] || []; + const hasActiveFilters = + selectedValues.length > 0; + const displayMode = + filter.displayMode || filterDisplayMode; + const displayText = + displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + return ( @@ -507,37 +542,54 @@ export function DataTable({ > {displayText} - {displayMode === "label" && hasActiveFilters && ( - - {selectedValues.length} - - )} + {displayMode === "label" && + hasActiveFilters && ( + + { + selectedValues.length + } + + )} - + {filter.label} - {filter.options.map((option) => { - const isChecked = selectedValues.includes(option.value); - return ( - - handleFilterChange( - filter.id, - option.value, + {filter.options.map( + (option) => { + const isChecked = + selectedValues.includes( + option.value + ); + return ( + e.preventDefault()} - > - {option.label} - - ); - })} + ) => + handleFilterChange( + filter.id, + option.value, + checked + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {option.label} + + ); + } + )} ); @@ -795,12 +847,14 @@ export function DataTable({
    - + {table.getRowModel().rows?.length > 0 && ( + + )}
    diff --git a/src/hooks/useNavigationContext.ts b/src/hooks/useNavigationContext.ts new file mode 100644 index 00000000..71b7c552 --- /dev/null +++ b/src/hooks/useNavigationContext.ts @@ -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 + }; +} diff --git a/src/lib/internalRedirect.ts b/src/lib/internalRedirect.ts index 115cea5c..6514db66 100644 --- a/src/lib/internalRedirect.ts +++ b/src/lib/internalRedirect.ts @@ -41,11 +41,12 @@ export function consumeInternalRedirectPath(): string | null { } /** - * Returns the full redirect target for an org: either `/${orgId}` or - * `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the - * stored value. + * Returns the full redirect target if a valid internal_redirect was stored + * (consumes the stored value). Returns null if none was stored or expired. + * 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(); - return path ? `/${orgId}${path}` : `/${orgId}`; + if (!path) return null; + return path.startsWith("/auth/") ? path : `/${orgId}${path}`; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f0dfa811..fe5350ff 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -16,11 +16,16 @@ import type { import type { ListTargetsResponse } from "@server/routers/target"; import type { ListUsersResponse } from "@server/routers/user"; 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 z from "zod"; import { remote } from "./api"; import { durationToMs } from "./durationToMs"; +import { wait } from "./wait"; export type ProductUpdate = { link: string | null; @@ -86,8 +91,7 @@ export const productUpdatesQueries = { }; export const clientFilterSchema = z.object({ - filter: z.enum(["machine", "user"]), - limit: z.int().prefault(1000).optional() + pageSize: z.int().prefault(1000).optional() }); export const orgQueries = { @@ -96,14 +100,13 @@ export const orgQueries = { filters }: { orgId: string; - filters: z.infer; + filters?: z.infer; }) => queryOptions({ queryKey: ["ORG", orgId, "CLIENTS", filters] as const, queryFn: async ({ signal, meta }) => { const sp = new URLSearchParams({ - ...filters, - limit: (filters.limit ?? 1000).toString() + pageSize: (filters?.pageSize ?? 1000).toString() }); const res = await meta!.api.get< @@ -188,19 +191,16 @@ export const logAnalyticsFiltersSchema = z.object({ .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) - .optional(), + .optional() + .catch(undefined), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) - .optional(), - resourceId: z - .string() - .optional() - .transform(Number) - .pipe(z.int().positive()) .optional() + .catch(undefined), + resourceId: z.coerce.number().optional().catch(undefined) }); export type LogAnalyticsFilters = z.TypeOf; @@ -361,22 +361,50 @@ export const approvalQueries = { orgId: string, filters: z.infer ) => - queryOptions({ + infiniteQueryOptions({ queryKey: ["APPROVALS", orgId, filters] as const, - queryFn: async ({ signal, meta }) => { + queryFn: async ({ signal, pageParam, meta }) => { const sp = new URLSearchParams(); if (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< - AxiosResponse<{ approvals: ApprovalItem[] }> + AxiosResponse<{ + approvals: ApprovalItem[]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; + }> >(`/org/${orgId}/approvals?${sp.toString()}`, { signal }); 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) => queryOptions({ @@ -388,6 +416,12 @@ export const approvalQueries = { signal }); return res.data.data.count; + }, + refetchInterval: (query) => { + if (query.state.data) { + return durationToMs(30, "seconds"); + } + return false; } }) }; diff --git a/src/lib/sortColumn.ts b/src/lib/sortColumn.ts new file mode 100644 index 00000000..fcb4cc98 --- /dev/null +++ b/src/lib/sortColumn.ts @@ -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; +} diff --git a/src/lib/types/sort.ts b/src/lib/types/sort.ts new file mode 100644 index 00000000..69161f5a --- /dev/null +++ b/src/lib/types/sort.ts @@ -0,0 +1 @@ +export type SortOrder = "asc" | "desc" | "indeterminate"; diff --git a/src/lib/validateLocalPath.ts b/src/lib/validateLocalPath.ts new file mode 100644 index 00000000..7f87eb44 --- /dev/null +++ b/src/lib/validateLocalPath.ts @@ -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 `*`" + ); + } +} \ No newline at end of file