Compare commits

...

100 Commits

Author SHA1 Message Date
Milo Schwartz e6a5cef945 Merge pull request #2371 from Fredkiss3/refactor/paginated-tables
feat: server side filtered, ordered & paginated tables
2026-02-14 11:43:01 -08:00
miloschwartz 4c8edb80b3 dont show table footer in client-side data-table 2026-02-14 11:40:59 -08:00
miloschwartz d4668fae99 add openapi types 2026-02-14 11:25:00 -08:00
Fred KISSIE ddfe55e3ae ♻️ add niceId to query filtering on most tables 2026-02-14 04:19:30 +01:00
Fred KISSIE 761a5f1d4c ♻️ use like & LOWER(column) for searching with query 2026-02-14 04:11:27 +01:00
Fred KISSIE 1fbcad8787 ♻️ refactor 2026-02-14 04:06:11 +01:00
miloschwartz aba586e605 change translation 2026-02-13 17:35:54 -08:00
Milo Schwartz 27b21b5ad4 Merge pull request #2359 from Fredkiss3/feat/logo-path-in-enterprise
feat: Support file path in branding logo URL for enterprise
2026-02-13 17:16:33 -08:00
Milo Schwartz b6e54dab17 Merge branch 'dev' into feat/logo-path-in-enterprise 2026-02-13 17:16:25 -08:00
miloschwartz 1f8e89772d disable global idp routes if idp mode is org 2026-02-13 15:46:13 -08:00
Owen be89e5ca55 Fix issue with auto provisioning being overriden 2026-02-13 14:56:56 -08:00
Fred KISSIE ab65bb6a8a Merge branch 'dev' into refactor/paginated-tables 2026-02-13 06:03:09 +01:00
miloschwartz 333625f199 rename starter in cloud to basic 2026-02-12 20:24:23 -08:00
Owen dbfd715381 Fix windows formatting 2026-02-12 16:27:51 -08:00
Owen Schwartz f1d989964e Merge pull request #2471 from fosrl/dev
Update translations
2026-02-12 16:14:41 -08:00
Owen Schwartz b701629498 Merge pull request #2465 from fosrl/crowdin_dev
New Crowdin updates
2026-02-12 16:14:17 -08:00
Owen Schwartz 8250946325 New translations en-us.json (Norwegian Bokmal) 2026-02-12 16:14:04 -08:00
Owen Schwartz 71f63d8e6f New translations en-us.json (Chinese Simplified) 2026-02-12 16:14:03 -08:00
Owen Schwartz dd5e834db0 New translations en-us.json (Turkish) 2026-02-12 16:14:01 -08:00
Owen Schwartz 970ecb52f0 New translations en-us.json (Russian) 2026-02-12 16:14:00 -08:00
Owen Schwartz 62ea1b40e1 New translations en-us.json (Portuguese) 2026-02-12 16:13:58 -08:00
Owen Schwartz 3b0fd5c592 New translations en-us.json (Polish) 2026-02-12 16:13:57 -08:00
Owen Schwartz b7616026dd New translations en-us.json (Dutch) 2026-02-12 16:13:55 -08:00
Owen Schwartz 16ad60b89a New translations en-us.json (Korean) 2026-02-12 16:13:54 -08:00
Owen Schwartz db7971d2f7 New translations en-us.json (Italian) 2026-02-12 16:13:53 -08:00
Owen Schwartz f3f8bd3125 New translations en-us.json (German) 2026-02-12 16:13:51 -08:00
Owen Schwartz 516fd0ee8f New translations en-us.json (Czech) 2026-02-12 16:13:50 -08:00
Owen Schwartz 8d6700d493 New translations en-us.json (Bulgarian) 2026-02-12 16:13:49 -08:00
Owen Schwartz 9d4ace9b3e New translations en-us.json (Spanish) 2026-02-12 16:13:47 -08:00
Owen Schwartz 2800655e33 New translations en-us.json (French) 2026-02-12 16:13:45 -08:00
Owen Schwartz 91eecee11d Merge pull request #2469 from fosrl/dev
1.15.4
2026-02-12 16:10:44 -08:00
Owen Schwartz 899e5aa395 New translations en-us.json (Norwegian Bokmal) 2026-02-12 15:41:25 -08:00
Owen Schwartz d5820c4902 New translations en-us.json (Chinese Simplified) 2026-02-12 15:41:24 -08:00
Owen Schwartz a91c002274 New translations en-us.json (Turkish) 2026-02-12 15:41:22 -08:00
Owen Schwartz 4d142b93dd New translations en-us.json (Russian) 2026-02-12 15:41:21 -08:00
Owen Schwartz 04dcf57ff3 New translations en-us.json (Portuguese) 2026-02-12 15:41:20 -08:00
Owen Schwartz 975550c755 New translations en-us.json (Polish) 2026-02-12 15:41:18 -08:00
Owen Schwartz a964a80d85 New translations en-us.json (Dutch) 2026-02-12 15:41:17 -08:00
Owen Schwartz 22c3b8f116 New translations en-us.json (Korean) 2026-02-12 15:41:15 -08:00
Owen Schwartz c4b1831cfe New translations en-us.json (Italian) 2026-02-12 15:41:14 -08:00
Owen Schwartz cdb6813384 New translations en-us.json (German) 2026-02-12 15:41:13 -08:00
Owen Schwartz b14b68d83c New translations en-us.json (Czech) 2026-02-12 15:41:11 -08:00
Owen Schwartz 3c2f930e6b New translations en-us.json (Bulgarian) 2026-02-12 15:41:10 -08:00
Owen Schwartz ca9c7ce555 New translations en-us.json (Spanish) 2026-02-12 15:41:08 -08:00
Owen Schwartz c2e95a0607 New translations en-us.json (French) 2026-02-12 15:41:06 -08:00
miloschwartz 2767ee9e80 update pangolin cli links 2026-02-12 15:29:08 -08:00
miloschwartz d998a8087f fix pg migration 2026-02-12 15:06:24 -08:00
miloschwartz fdce016921 add 1.15.4 migration 2026-02-12 15:00:12 -08:00
miloschwartz c73d70933b bump version 2026-02-12 14:52:29 -08:00
miloschwartz e9d0ad6e37 use pangolin cli for container 2026-02-12 14:51:25 -08:00
Owen a35586f762 Add sudo 2026-02-12 14:47:55 -08:00
miloschwartz f527c30923 add post auth url 2026-02-12 14:21:50 -08:00
Owen 94e70219cf Make install sudo because run is sudo 2026-02-12 14:12:45 -08:00
Owen 6496763aae Cap retention days 2026-02-12 12:18:42 -08:00
Owen a409ec269b Change back to lokowitz db method 2026-02-12 12:13:13 -08:00
Owen bc7bc8da66 Stop tracking files that should be ignored 2026-02-12 12:07:57 -08:00
Owen 52484c774e Setting up drizzle and fix site not showing in private resource 2026-02-12 12:05:15 -08:00
Owen Schwartz 69ecc22318 New translations en-us.json (German) 2026-02-12 01:39:38 -08:00
Owen bff9d33ee6 Move back to db:sqlite:generate 2026-02-11 21:47:10 -08:00
Fred KISSIE 6d1665004b 🏷️ fix type errors 2026-02-11 04:34:53 +01:00
Fred KISSIE 59b8119fbd Merge branch 'dev' into refactor/paginated-tables 2026-02-11 04:12:40 +01:00
Fred KISSIE 45cd4df6e5 ♻️ agent 2026-02-11 00:37:42 +01:00
Fred KISSIE d6ade102dc filter & paginate on machine clients table 2026-02-10 05:14:37 +01:00
Fred KISSIE c94d246c24 ♻️ list machine query 2026-02-10 04:00:45 +01:00
Fred KISSIE 5b779ba9fe ♻️ refactor 2026-02-10 03:21:12 +01:00
Fred KISSIE 3ba2cb19a9 approval feed 2026-02-10 03:20:49 +01:00
Fred KISSIE da514ef314 ♻️ refactor 2026-02-10 00:45:34 +01:00
Fred KISSIE 7f73cde794 ♻️ refetch approval count every 30s 2026-02-10 00:45:20 +01:00
Fred KISSIE b0af0d9cd5 ♻️ keep previous data 2026-02-10 00:31:21 +01:00
Fred KISSIE ff61b22e7e ♻️do not set default values 2026-02-07 05:37:52 +01:00
Fred KISSIE 577cb91343 whole table filter 2026-02-07 05:37:01 +01:00
Fred KISSIE 1889386f64 🚧 wip: table filters 2026-02-07 04:51:37 +01:00
Fred KISSIE 5d7f082ebf sort user device table & refactor sort into common functino 2026-02-07 04:41:42 +01:00
Fred KISSIE db6327c4ff 🔇 remove console.logs in API 2026-02-07 02:52:23 +01:00
Fred KISSIE fd7f6b2b99 filter user devices API finished 2026-02-07 02:51:32 +01:00
Fred KISSIE 49435398a8 🔥 cleanup imports 2026-02-07 02:50:59 +01:00
Fred KISSIE 9f2fd34e99 🚧 wip: user devices endpoint 2026-02-06 05:37:44 +01:00
Fred KISSIE 67b63d3084 ♻️ make code cleanrer 2026-02-06 04:52:21 +01:00
Fred KISSIE 4a31a7b84b 🚨 fix lint error 2026-02-06 03:55:11 +01:00
Fred KISSIE 538b601b1e Merge branch 'dev' into refactor/paginated-tables 2026-02-06 03:54:50 +01:00
Fred KISSIE 588f064c25 🚸 make resource enabled switch optimistic 2026-02-06 03:53:14 +01:00
Fred KISSIE d521e79662 🏷️ fix types 2026-02-06 03:21:00 +01:00
Fred KISSIE ccddb9244d 🏷️ add types on mode in sqlite 2026-02-06 03:14:03 +01:00
Fred KISSIE 0547396213 ♻️ do not sort client resources 2026-02-06 02:44:23 +01:00
Fred KISSIE 6c85171091 serverside filter+paginate client resources table 2026-02-06 02:42:15 +01:00
Fred KISSIE 609ffccd67 🏷️ fix typescript error 2026-02-05 05:35:59 +01:00
Fred KISSIE 748af1d8cb ♻️ cleanup code for searching & filtering 2026-02-05 05:21:25 +01:00
Fred KISSIE d309ec249e filter resources by status 2026-02-05 03:15:18 +01:00
Fred KISSIE 67949b4968 🚧 wip: healthStatus 2026-02-04 04:10:08 +01:00
Fred KISSIE 1fc40b3017 filter by auth state 2026-02-04 03:42:05 +01:00
Fred KISSIE bb1a375484 paginate, search & filter resources by enabled 2026-02-04 02:20:28 +01:00
Fred KISSIE cda6b67bef search, filter & paginate sites table 2026-01-31 03:02:39 +01:00
Fred KISSIE 066305b095 toggle column sorting & pagination 2026-01-31 00:45:14 +01:00
Fred KISSIE 89695df012 🚧 wip: pagination and search work 2026-01-30 05:39:01 +01:00
Fred KISSIE b04385a340 🚧 search on table 2026-01-29 05:48:41 +01:00
Fred KISSIE d374ea6ea6 🚧wip 2026-01-29 05:07:41 +01:00
Fred KISSIE 01a2820390 🚧 POC: pagination in sites table 2026-01-29 05:07:27 +01:00
Fred KISSIE c89c1a03da 🎨 use prettier for formatting typescript 2026-01-29 05:05:34 +01:00
Fred KISSIE 38ac4c5980 🚧 wip: paginated tables 2026-01-28 04:46:54 +01:00
Fred KISSIE ed3ee64e4b support pathname in logo URL in branding page 2026-01-28 03:04:12 +01:00
87 changed files with 3815 additions and 1494 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: Public Pipeline
name: Public CICD Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
run: npm run db:generate
- name: Apply database migrations
run: npm run db:sqlite:push
run: npm run db:push
- name: Test with tsc
run: npx tsc --noEmit
+2 -1
View File
@@ -51,4 +51,5 @@ dynamic/
scratch/
tsconfig.json
hydrateSaas.ts
CLAUDE.md
CLAUDE.md
drizzle.config.ts
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -16,7 +16,7 @@ COPY . .
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run set:$DATABASE && \
npm run set:$BUILD && \
npm run db:$DATABASE:generate && \
npm run db:generate && \
npm run build && \
npm run build:cli
-14
View File
@@ -1,14 +0,0 @@
import { defineConfig } from "drizzle-kit";
import path from "path";
const schema = [path.join("server", "db", "pg", "schema")];
export default defineConfig({
dialect: "postgresql",
schema: schema,
out: path.join("server", "migrations"),
verbose: true,
dbCredentials: {
url: process.env.DATABASE_URL as string
}
});
+3 -3
View File
@@ -1154,7 +1154,7 @@
"actionDeleteClient": "Client löschen",
"actionArchiveClient": "Client archivieren",
"actionUnarchiveClient": "Client dearchivieren",
"actionBlockClient": "Klient sperren",
"actionBlockClient": "Client sperren",
"actionUnblockClient": "Client entsperren",
"actionUpdateClient": "Client aktualisieren",
"actionListClients": "Clients auflisten",
@@ -2532,10 +2532,10 @@
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
"archiveClientConfirm": "Client archivieren",
"blockClient": "Klient sperren",
"blockClient": "Client sperren",
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
"blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.",
"blockClientConfirm": "Klient sperren",
"blockClientConfirm": "Client sperren",
"active": "Aktiv",
"usernameOrEmail": "Benutzername oder E-Mail",
"selectYourOrganization": "Wählen Sie Ihre Organisation",
+8 -2
View File
@@ -461,6 +461,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 +1171,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 +1919,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)",
@@ -2060,7 +2066,7 @@
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Olm Container",
"machineClientsBannerOlmContainer": "Container",
"clientsTableUserClients": "User",
"clientsTableMachineClients": "Machine",
"licenseTableValidUntil": "Valid Until",
+13 -1
View File
@@ -97,6 +97,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",
@@ -13918,7 +13919,6 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -21122,6 +21122,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.7.0",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.7.0.tgz",
+6 -8
View File
@@ -13,13 +13,10 @@
"scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
"db:push": "npx tsx server/db/migrate.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
"db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
@@ -123,6 +120,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",
@@ -147,6 +145,7 @@
"@types/express": "5.0.6",
"@types/express-session": "1.18.2",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "24.10.2",
"@types/nodemailer": "7.0.4",
@@ -159,7 +158,6 @@
"@types/topojson-client": "3.1.5",
"@types/ws": "8.18.1",
"@types/yargs": "17.0.35",
"@types/js-yaml": "4.0.9",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.8",
"esbuild": "0.27.2",
+4 -4
View File
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
You must have a connection string in your config file, as shown above.
```bash
npm run db:pg:generate
npm run db:pg:push
npm run db:generate
npm run db:push
```
### SQLite
```bash
npm run db:sqlite:generate
npm run db:sqlite:push
npm run db:generate
npm run db:push
```
## Build Time
+3
View File
@@ -0,0 +1,3 @@
import { runMigrations } from "./";
await runMigrations();
+1
View File
@@ -1,3 +1,4 @@
export * from "./driver";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
+1 -3
View File
@@ -4,7 +4,7 @@ import path from "path";
const migrationsFolder = path.join("server/migrations");
const runMigrations = async () => {
export const runMigrations = async () => {
console.log("Running migrations...");
try {
await migrate(db as any, {
@@ -17,5 +17,3 @@ const runMigrations = async () => {
process.exit(1);
}
};
runMigrations();
+18 -17
View File
@@ -1,18 +1,16 @@
import {
pgTable,
serial,
varchar,
boolean,
integer,
bigint,
real,
text,
index,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { randomUUID } from "crypto";
import { 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(),
@@ -142,7 +140,8 @@ export const resources = pgTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
});
export const targets = pgTable("targets", {
@@ -187,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")
});
@@ -217,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
+1
View File
@@ -1,3 +1,4 @@
export * from "./driver";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
+1 -3
View File
@@ -4,7 +4,7 @@ import path from "path";
const migrationsFolder = path.join("server/migrations");
const runMigrations = async () => {
export const runMigrations = async () => {
console.log("Running migrations...");
try {
migrate(db as any, {
@@ -16,5 +16,3 @@ const runMigrations = async () => {
process.exit(1);
}
};
runMigrations();
+7 -11
View File
@@ -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(),
@@ -162,7 +155,8 @@ export const resources = sqliteTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
});
export const targets = sqliteTable("targets", {
@@ -213,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")
});
@@ -245,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
+4 -4
View File
@@ -15,10 +15,10 @@ export const sandboxLimitSet: LimitSet = {
};
export const freeLimitSet: LimitSet = {
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
};
export const tier1LimitSet: LimitSet = {
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.15.3";
export const APP_VERSION = "1.15.4";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
+18
View File
@@ -0,0 +1,18 @@
/**
* Normalizes a post-authentication path for safe use when building redirect URLs.
* Returns a path that starts with / and does not allow open redirects (no //, no :).
*/
export function normalizePostAuthPath(path: string | null | undefined): string | null {
if (path == null || typeof path !== "string") {
return null;
}
const trimmed = path.trim();
if (trimmed === "") {
return null;
}
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
if (trimmed.includes("//") || trimmed.includes(":")) {
return null;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
@@ -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)
)
);
+108 -53
View File
@@ -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<string>() // 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<string>()
.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<string>()
.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<typeof querySchema>["approvalState"],
clientId?: number
) {
async function queryApprovals({
orgId,
limit,
approvalState,
cursorPending,
cursorTimestamp,
clientId
}: {
orgId: string;
limit: number;
approvalState: z.infer<typeof querySchema>["approvalState"];
cursorPending?: number;
cursorTimestamp?: number;
clientId?: number;
}) {
let state: Array<Approval["decision"]> = [];
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<Awaited<ReturnType<typeof queryApprovals>>>;
pagination: { total: number; limit: number; offset: number };
approvals: NonNullable<
Awaited<ReturnType<typeof queryApprovals>>
>["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<number>`count(*)` })
@@ -237,7 +291,8 @@ export async function listApprovals(
pagination: {
total: count,
limit,
offset
cursorPending: nextCursorPending,
cursorTimestamp: nextCursorTimestamp
}
},
success: true,
@@ -18,6 +18,113 @@ import logger from "@server/logger";
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
* Returns null for enterprise tier (unlimited)
*/
function getMaxRetentionDaysForTier(tier: Tier | null): number | null {
if (!tier) {
return 3; // Free tier
}
switch (tier) {
case "tier1":
return 7;
case "tier2":
return 30;
case "tier3":
return 90;
case "enterprise":
return null; // No limit
default:
return 3; // Default to free tier limit
}
}
/**
* Cap retention days to the maximum allowed for the given tier
*/
async function capRetentionDays(
orgId: string,
tier: Tier | null
): Promise<void> {
const maxRetentionDays = getMaxRetentionDaysForTier(tier);
// If there's no limit (enterprise tier), no capping needed
if (maxRetentionDays === null) {
logger.debug(
`No retention day limit for org ${orgId} on tier ${tier || "free"}`
);
return;
}
// Get current org settings
const [org] = await db
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org) {
logger.warn(`Org ${orgId} not found when capping retention days`);
return;
}
const updates: Partial<typeof orgs.$inferInsert> = {};
let needsUpdate = false;
// Cap request log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysRequest !== null &&
org.settingsLogRetentionDaysRequest > maxRetentionDays
) {
updates.settingsLogRetentionDaysRequest = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Cap access log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysAccess !== null &&
org.settingsLogRetentionDaysAccess > maxRetentionDays
) {
updates.settingsLogRetentionDaysAccess = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Cap action log retention if it exceeds the limit
if (
org.settingsLogRetentionDaysAction !== null &&
org.settingsLogRetentionDaysAction > maxRetentionDays
) {
updates.settingsLogRetentionDaysAction = maxRetentionDays;
needsUpdate = true;
logger.info(
`Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}`
);
}
// Apply updates if needed
if (needsUpdate) {
await db
.update(orgs)
.set(updates)
.where(eq(orgs.orgId, orgId));
logger.info(
`Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days`
);
} else {
logger.debug(
`No retention day capping needed for org ${orgId}`
);
}
}
export async function handleTierChange(
orgId: string,
newTier: SubscriptionType | null,
@@ -40,6 +147,9 @@ export async function handleTierChange(
logger.info(
`Org ${orgId} is reverting to free tier, disabling all paid features`
);
// Cap retention days to free tier limits
await capRetentionDays(orgId, null);
// Disable all features in the tier matrix
for (const [featureKey] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;
@@ -57,6 +167,9 @@ export async function handleTierChange(
// Get the tier (cast as Tier since we've ruled out "license" and null)
const tier = newTier as Tier;
// Cap retention days to the new tier's limits
await capRetentionDays(orgId, tier);
// Check each feature in the tier matrix
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
const feature = featureKey as TierFeature;
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
next: NextFunction
): Promise<any> {
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,8 +117,11 @@ export async function generateNewEnterpriseLicense(
);
}
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]
const tier =
licenseData.tier === "big_license"
? LicenseId.BIG_LICENSE
: LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier];
const session = await stripe!.checkout.sessions.create({
client_reference_id: keyId.toString(),
@@ -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",
@@ -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) {
@@ -28,6 +28,7 @@ import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
import { build } from "@server/build";
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
@@ -122,12 +123,14 @@ export async function createOrgOidcIdp(
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
}
const key = config.getRawConfig().server.secret!;
@@ -27,6 +27,7 @@ import config from "@server/lib/config";
import { isSubscribed } from "#private/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import privateConfig from "#private/lib/config";
import { build } from "@server/build";
const paramsSchema = z
.object({
@@ -127,12 +128,15 @@ export async function updateOrgOidcIdp(
let { autoProvision } = parsedBody.data;
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
if (build == "saas") {
// this is not paywalled with a ee license because this whole endpoint is restricted
const subscribed = await isSubscribed(
orgId,
tierMatrix.deviceApprovals
);
if (!subscribed) {
autoProvision = false;
}
}
// Check if IDP exists and is of type OIDC
+2 -2
View File
@@ -797,7 +797,7 @@ async function notAllowed(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(
const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature
orgId,
tierMatrix.loginPageDomain
);
@@ -854,7 +854,7 @@ async function headerAuthChallenged(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain);
const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature
if (subscribed) {
loginPage = await getOrgLoginPage(orgId);
}
+1
View File
@@ -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";
+161 -79
View File
@@ -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<string>() // 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<string>() // 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<ReturnType<typeof queryClients>>[0] & {
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
sites: Array<{
siteId: number;
siteName: string | null;
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
type OlmWithUpdateAvailable = ClientWithSites;
export type ListClientsResponse = {
export type ListClientsResponse = PaginatedResponse<{
clients: Array<ClientWithSites>;
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<unknown> | 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,
+500
View File
@@ -0,0 +1,500 @@
import { build } from "@server/build";
import {
clients,
currentFingerprint,
db,
olms,
orgs,
roleClients,
userClients,
users
} from "@server/db";
import { getUserDeviceName } from "@server/db/names";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import {
and,
asc,
desc,
eq,
inArray,
isNotNull,
isNull,
like,
or,
sql,
type SQL
} from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import semver from "semver";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
async function getLatestOlmVersion(): Promise<string | null> {
try {
const cachedVersion = olmVersionCache.get<string>("latestOlmVersion");
if (cachedVersion) {
return cachedVersion;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const response = await fetch(
"https://api.github.com/repos/fosrl/olm/tags",
{
signal: controller.signal
}
);
clearTimeout(timeoutId);
if (!response.ok) {
logger.warn(
`Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}`
);
return null;
}
let tags = await response.json();
if (!Array.isArray(tags) || tags.length === 0) {
logger.warn("No tags found for Olm repository");
return null;
}
tags = tags.filter((version) => !version.name.includes("rc"));
const latestVersion = tags[0].name;
olmVersionCache.set("latestOlmVersion", latestVersion);
return latestVersion;
} catch (error: any) {
if (error.name === "AbortError") {
logger.warn("Request to fetch latest Olm version timed out (1.5s)");
} else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") {
logger.warn("Connection timeout while fetching latest Olm version");
} else {
logger.warn(
"Error fetching latest Olm version:",
error.message || error
);
}
return null;
}
}
const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserDevicesSchema = z.object({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()
.positive()
.optional()
.catch(20)
.default(20)
.openapi({
type: "integer",
default: 20,
description: "Number of items per page"
}),
page: z.coerce
.number<string>() // for prettier formatting
.int()
.min(0)
.optional()
.catch(1)
.default(1)
.openapi({
type: "integer",
default: 1,
description: "Page number to retrieve"
}),
query: z.string().optional(),
sort_by: z
.enum(["megabytesIn", "megabytesOut"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["megabytesIn", "megabytesOut"],
description: "Field to sort by"
}),
order: z
.enum(["asc", "desc"])
.optional()
.default("asc")
.catch("asc")
.openapi({
type: "string",
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
online: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter by online status"
}),
agent: z
.enum([
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: [
"windows",
"android",
"cli",
"olm",
"macos",
"ios",
"ipados",
"unknown"
],
description:
"Filter by agent type. Use 'unknown' to filter clients with no agent detected."
}),
status: z.preprocess(
(val: string | undefined) => {
if (val) {
return val.split(","); // the search query array is an array joined by commas
}
return undefined;
},
z
.array(
z.enum(["active", "pending", "denied", "blocked", "archived"])
)
.optional()
.default(["active", "pending"])
.catch(["active", "pending"])
.openapi({
type: "array",
items: {
type: "string",
enum: ["active", "pending", "denied", "blocked", "archived"]
},
default: ["active", "pending"],
description:
"Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled."
})
)
});
function queryUserDevicesBase() {
return db
.select({
clientId: clients.clientId,
orgId: clients.orgId,
name: clients.name,
pubKey: clients.pubKey,
subnet: clients.subnet,
megabytesIn: clients.megabytesIn,
megabytesOut: clients.megabytesOut,
orgName: orgs.name,
type: clients.type,
online: clients.online,
olmVersion: olms.version,
userId: clients.userId,
username: users.username,
userEmail: users.email,
niceId: clients.niceId,
agent: olms.agent,
approvalState: clients.approvalState,
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
}
type OlmWithUpdateAvailable = Awaited<
ReturnType<typeof queryUserDevicesBase>
>[0] & {
olmUpdateAvailable?: boolean;
};
export type ListUserDevicesResponse = PaginatedResponse<{
devices: Array<OlmWithUpdateAvailable>;
}>;
registry.registerPath({
method: "get",
path: "/org/{orgId}/user-devices",
description: "List all user devices for an organization.",
tags: [OpenAPITags.Client, OpenAPITags.Org],
request: {
query: listUserDevicesSchema,
params: listUserDevicesParamsSchema
},
responses: {}
});
export async function listUserDevices(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listUserDevicesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { page, pageSize, query, sort_by, online, status, agent, order } =
parsedQuery.data;
const parsedParams = listUserDevicesParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { orgId } = parsedParams.data;
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
let accessibleClients;
if (req.user) {
accessibleClients = await db
.select({
clientId: sql<number>`COALESCE(${userClients.clientId}, ${roleClients.clientId})`
})
.from(userClients)
.fullJoin(
roleClients,
eq(userClients.clientId, roleClients.clientId)
)
.where(
or(
eq(userClients.userId, req.user!.userId),
eq(roleClients.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleClients = await db
.select({ clientId: clients.clientId })
.from(clients)
.where(eq(clients.orgId, orgId));
}
const accessibleClientIds = accessibleClients.map(
(client) => client.clientId
);
// Get client count with filter
const conditions = [
and(
inArray(clients.clientId, accessibleClientIds),
eq(clients.orgId, orgId),
isNotNull(clients.userId)
)
];
if (query) {
conditions.push(
or(
like(
sql`LOWER(${clients.name})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${clients.niceId})`,
"%" + query.toLowerCase() + "%"
),
like(
sql`LOWER(${users.email})`,
"%" + query.toLowerCase() + "%"
)
)
);
}
if (typeof online !== "undefined") {
conditions.push(eq(clients.online, online));
}
const agentValueMap = {
windows: "Pangolin Windows",
android: "Pangolin Android",
ios: "Pangolin iOS",
ipados: "Pangolin iPadOS",
macos: "Pangolin macOS",
cli: "Pangolin CLI",
olm: "Olm CLI"
} satisfies Record<
Exclude<typeof agent, undefined | "unknown">,
string
>;
if (typeof agent !== "undefined") {
if (agent === "unknown") {
conditions.push(isNull(olms.agent));
} else {
conditions.push(eq(olms.agent, agentValueMap[agent]));
}
}
if (status.length > 0) {
const filterAggregates: (SQL<unknown> | undefined)[] = [];
if (status.includes("active")) {
filterAggregates.push(
and(
eq(clients.archived, false),
eq(clients.blocked, false),
build !== "oss"
? or(
eq(clients.approvalState, "approved"),
isNull(clients.approvalState) // approval state of `NULL` means approved by default
)
: undefined // undefined are automatically ignored by `drizzle-orm`
)
);
}
if (status.includes("archived")) {
filterAggregates.push(eq(clients.archived, true));
}
if (status.includes("blocked")) {
filterAggregates.push(eq(clients.blocked, true));
}
if (build !== "oss") {
if (status.includes("pending")) {
filterAggregates.push(eq(clients.approvalState, "pending"));
}
if (status.includes("denied")) {
filterAggregates.push(eq(clients.approvalState, "denied"));
}
}
conditions.push(or(...filterAggregates));
}
const baseQuery = queryUserDevicesBase().where(and(...conditions));
const countQuery = db.$count(baseQuery.as("filtered_clients"));
const listDevicesQuery = baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(
sort_by
? order === "asc"
? asc(clients[sort_by])
: desc(clients[sort_by])
: asc(clients.clientId)
);
const [clientsList, totalCount] = await Promise.all([
listDevicesQuery,
countQuery
]);
// Merge clients with their site associations and replace name with device name
const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map(
(client) => {
const model = client.deviceModel || null;
const newName = getUserDeviceName(model, client.name);
const OlmWithUpdate: OlmWithUpdateAvailable = {
...client,
name: newName
};
// Initially set to false, will be updated if version check succeeds
OlmWithUpdate.olmUpdateAvailable = false;
return OlmWithUpdate;
}
);
// Try to get the latest version, but don't block if it fails
try {
const latestOlmVersion = await getLatestOlmVersion();
if (latestOlmVersion) {
olmsWithUpdates.forEach((client) => {
try {
client.olmUpdateAvailable = semver.lt(
client.olmVersion ? client.olmVersion : "",
latestOlmVersion
);
} catch (error) {
client.olmUpdateAvailable = false;
}
});
}
} catch (error) {
// Log the error but don't let it block the response
logger.warn(
"Failed to check for OLM updates, continuing without update info:",
error
);
}
return response<ListUserDevicesResponse>(res, {
data: {
devices: olmsWithUpdates,
pagination: {
total: totalCount,
page,
pageSize
}
},
success: true,
error: false,
message: "Clients retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+7
View File
@@ -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,
+9
View File
@@ -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)
+11
View File
@@ -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);
+9
View File
@@ -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()
+9
View File
@@ -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()
+7
View File
@@ -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,
@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import config from "@server/lib/config";
import stoi from "@server/lib/stoi";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
const authWithAccessTokenBodySchema = z.strictObject({
accessToken: z.string(),
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
requestIp: req.ip
});
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
if (postAuthPath) {
redirectUrl = redirectUrl + postAuthPath;
}
return response<AuthWithAccessTokenResponse>(res, {
data: {
session: token,
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
redirectUrl
},
success: true,
error: false,
+5 -3
View File
@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
http: z.boolean(),
protocol: z.enum(["tcp", "udp"]),
domainId: z.string(),
stickySession: z.boolean().optional()
stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional()
})
.refine(
(data) => {
@@ -188,7 +189,7 @@ async function createHttpResource(
);
}
const { name, domainId } = parsedBody.data;
const { name, domainId, postAuthPath } = parsedBody.data;
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
@@ -255,7 +256,8 @@ async function createHttpResource(
http: true,
protocol: "tcp",
ssl: true,
stickySession: stickySession
stickySession: stickySession,
postAuthPath: postAuthPath
})
.returning();
@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
whitelist: boolean;
skipToIdpId: number | null;
orgId: string;
postAuthPath: string | null;
};
export async function getResourceAuthInfo(
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
url,
whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId
orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
},
success: true,
error: false,
+240 -105
View File
@@ -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<string>() // 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<string>() // 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<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
ELSE 0
END
) `;
function queryResourcesBase() {
return db
.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<any> | undefined = sql`1 = 1`;
if (typeof healthStatus !== "undefined") {
switch (healthStatus) {
case "healthy":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = ${total_targets}`
);
break;
case "degraded":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unhealthy_targets} > 0`
);
break;
case "no_targets":
aggregateFilters = sql`${total_targets} = 0`;
break;
case "offline":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = 0`,
sql`${unhealthy_targets} = ${total_targets}`
);
break;
case "unknown":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unknown_targets} = ${total_targets}`
);
break;
}
}
const baseQuery = queryResourcesBase()
.where(and(...conditions))
.having(aggregateFilters);
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_resources"));
const [rows, totalCount] = await Promise.all([
baseQuery
.limit(pageSize)
.offset(pageSize * (page - 1))
.orderBy(asc(resources.resourceId)),
countQuery
]);
const resourceIdList = rows.map((row) => row.resourceId);
const allResourceTargets =
resourceIdList.length === 0
? []
: await db
.select({
targetId: targets.targetId,
resourceId: targets.resourceId,
ip: targets.ip,
port: targets.port,
enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
.leftJoin(
targetHealthCheck,
eq(targetHealthCheck.targetId, targets.targetId)
);
// avoids TS issues with reduce/never[]
const map = new Map<number, ResourceWithTargets>();
@@ -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<ListResourcesResponse>(res, {
data: {
resources: resourcesList,
pagination: {
total: totalCount,
limit,
offset
pageSize,
page
}
},
success: true,
+2 -1
View File
@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional()
maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
+128 -53
View File
@@ -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<string | null> {
try {
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
});
const listSitesSchema = z.object({
limit: z
.string()
pageSize: z.coerce
.number<string>() // 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<string>() // 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<ReturnType<typeof querySites>>[0] & {
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[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,
@@ -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,
@@ -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<string>() // 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<string>() // 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<ListAllSiteResourcesByOrgResponse>(res, {
data: {
siteResources: siteResourcesList,
pagination: {
total: totalCount,
pageSize,
page
}
},
success: true,
error: false,
message: "Site resources retrieved successfully",
@@ -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();
+1
View File
@@ -0,0 +1 @@
migrations.ts
-162
View File
@@ -1,162 +0,0 @@
#! /usr/bin/env node
import { migrate } from "drizzle-orm/node-postgres/migrator";
import { db } from "../db/pg";
import semver from "semver";
import { versionMigrations } from "../db/pg";
import { __DIRNAME, APP_VERSION } from "@server/lib/consts";
import path from "path";
import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0";
import m5 from "./scriptsPg/1.10.0";
import m6 from "./scriptsPg/1.10.2";
import m7 from "./scriptsPg/1.11.0";
import m8 from "./scriptsPg/1.11.1";
import m9 from "./scriptsPg/1.12.0";
import m10 from "./scriptsPg/1.13.0";
import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
// Define the migration list with versions and their corresponding functions
const migrations = [
{ version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 },
{ version: "1.10.0", run: m5 },
{ version: "1.10.2", run: m6 },
{ version: "1.11.0", run: m7 },
{ version: "1.11.1", run: m8 },
{ version: "1.12.0", run: m9 },
{ version: "1.13.0", run: m10 },
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 }
// Add new migrations here as they are created
] as {
version: string;
run: () => Promise<void>;
}[];
await run();
async function run() {
// run the migrations
await runMigrations();
}
export async function runMigrations() {
if (process.env.DISABLE_MIGRATIONS) {
console.log("Migrations are disabled. Skipping...");
return;
}
try {
const appVersion = APP_VERSION;
// determine if the migrations table exists
const exists = await db
.select()
.from(versionMigrations)
.limit(1)
.execute()
.then((res) => res.length > 0)
.catch(() => false);
if (exists) {
console.log("Migrations table exists, running scripts...");
await executeScripts();
} else {
console.log("Migrations table does not exist, creating it...");
console.log("Running migrations...");
try {
await migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
await migration.run();
// Update version in database
await db
.insert(versionMigrations)
.values({
version: migration.version,
executedAt: Date.now()
})
.execute();
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (e) {
if (
e instanceof Error &&
typeof (e as any).code === "string" &&
(e as any).code === "23505"
) {
console.error("Migration has already run! Skipping...");
continue; // or return, depending on context
}
console.error(
`Failed to run migration ${migration.version}:`,
e
);
throw e;
}
}
console.log("All migrations completed successfully");
} catch (error) {
console.error("Migration process failed:", error);
throw error;
}
}
+3 -1
View File
@@ -18,6 +18,7 @@ import m10 from "./scriptsPg/1.13.0";
import m11 from "./scriptsPg/1.14.0";
import m12 from "./scriptsPg/1.15.0";
import m13 from "./scriptsPg/1.15.3";
import m14 from "./scriptsPg/1.15.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -36,7 +37,8 @@ const migrations = [
{ version: "1.13.0", run: m10 },
{ version: "1.14.0", run: m11 },
{ version: "1.15.0", run: m12 },
{ version: "1.15.3", run: m13 }
{ version: "1.15.3", run: m13 },
{ version: "1.15.4", run: m14 }
// Add new migrations here as they are created
] as {
version: string;
+3 -1
View File
@@ -36,6 +36,7 @@ import m31 from "./scriptsSqlite/1.13.0";
import m32 from "./scriptsSqlite/1.14.0";
import m33 from "./scriptsSqlite/1.15.0";
import m34 from "./scriptsSqlite/1.15.3";
import m35 from "./scriptsSqlite/1.15.4";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -70,7 +71,8 @@ const migrations = [
{ version: "1.13.0", run: m31 },
{ version: "1.14.0", run: m32 },
{ version: "1.15.0", run: m33 },
{ version: "1.15.3", run: m34 }
{ version: "1.15.3", run: m34 },
{ version: "1.15.4", run: m35 }
// Add new migrations here as they are created
] as const;
+27
View File
@@ -0,0 +1,27 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts";
const version = "1.15.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(
sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;`
);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}
+27
View File
@@ -0,0 +1,27 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.15.4";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.transaction(() => {
db.prepare(
`ALTER TABLE 'resources' ADD 'postAuthPath' text;`
).run();
})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}
+5
View File
@@ -0,0 +1,5 @@
export type Pagination = { total: number; pageSize: number; page: number };
export type PaginatedResponse<T> = T & {
pagination: Pagination;
};
@@ -61,7 +61,7 @@ import {
import { FeatureId } from "@server/lib/billing/features";
// Plan tier definitions matching the mockup
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
type PlanOption = {
id: PlanId;
@@ -73,8 +73,8 @@ type PlanOption = {
const planOptions: PlanOption[] = [
{
id: "starter",
name: "Starter",
id: "basic",
name: "Basic",
price: "Free",
tierType: null
},
@@ -109,10 +109,10 @@ const planOptions: PlanOption[] = [
// Tier limits mapping derived from limit sets
const tierLimits: Record<
Tier | "starter",
Tier | "basic",
{ users: number; sites: number; domains: number; remoteNodes: number }
> = {
starter: {
basic: {
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
@@ -183,7 +183,7 @@ export default function BillingPage() {
// Confirmation dialog state
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingTier, setPendingTier] = useState<{
tier: Tier | "starter";
tier: Tier | "basic";
action: "upgrade" | "downgrade";
planName: string;
price: string;
@@ -402,8 +402,8 @@ export default function BillingPage() {
pendingTier.action === "upgrade" ||
pendingTier.action === "downgrade"
) {
// If downgrading to starter (free tier), go to Stripe portal
if (pendingTier.tier === "starter") {
// If downgrading to basic (free tier), go to Stripe portal
if (pendingTier.tier === "basic") {
handleModifySubscription();
} else if (hasSubscription) {
handleChangeTier(pendingTier.tier);
@@ -417,7 +417,7 @@ export default function BillingPage() {
};
const showTierConfirmation = (
tier: Tier | "starter",
tier: Tier | "basic",
action: "upgrade" | "downgrade",
planName: string,
price: string
@@ -432,9 +432,9 @@ export default function BillingPage() {
// Get current plan ID from tier
const getCurrentPlanId = (): PlanId => {
if (!hasSubscription || !currentTier) return "starter";
if (!hasSubscription || !currentTier) return "basic";
const plan = planOptions.find((p) => p.tierType === currentTier);
return plan?.id || "starter";
return plan?.id || "basic";
};
const currentPlanId = getCurrentPlanId();
@@ -451,8 +451,8 @@ export default function BillingPage() {
}
if (plan.id === currentPlanId) {
// If it's the starter plan (starter with no subscription), show as current but disabled
if (plan.id === "starter" && !hasSubscription) {
// If it's the basic plan (basic with no subscription), show as current but disabled
if (plan.id === "basic" && !hasSubscription) {
return {
label: "Current Plan",
action: () => {},
@@ -484,10 +484,10 @@ export default function BillingPage() {
plan.name,
plan.price + (" " + plan.priceDetail || "")
);
} else if (plan.id === "starter") {
// Show confirmation for downgrading to starter (free tier)
} else if (plan.id === "basic") {
// Show confirmation for downgrading to basic (free tier)
showTierConfirmation(
"starter",
"basic",
"downgrade",
plan.name,
plan.price
@@ -566,7 +566,7 @@ export default function BillingPage() {
};
// Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = (targetTier: Tier | "starter"): Array<{
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
feature: string;
currentUsage: number;
newLimit: number;
@@ -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<Record<string, string>>;
};
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<ListClientsResponse>
>(
`/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) {
<MachineClientsTable
machineClients={machineClientRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
@@ -602,7 +602,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.biometricsEnabled
.biometricsEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -622,7 +623,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.diskEncrypted
.diskEncrypted ===
true
)
: "-"}
</InfoSectionContent>
@@ -642,7 +644,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.firewallEnabled
.firewallEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -663,7 +666,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.autoUpdatesEnabled
.autoUpdatesEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -683,7 +687,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.tpmAvailable
.tpmAvailable ===
true
)
: "-"}
</InfoSectionContent>
@@ -707,7 +712,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.windowsAntivirusEnabled
.windowsAntivirusEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -727,7 +733,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.macosSipEnabled
.macosSipEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -751,7 +758,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.macosGatekeeperEnabled
.macosGatekeeperEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -775,7 +783,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.macosFirewallStealthMode
.macosFirewallStealthMode ===
true
)
: "-"}
</InfoSectionContent>
@@ -796,7 +805,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.linuxAppArmorEnabled
.linuxAppArmorEnabled ===
true
)
: "-"}
</InfoSectionContent>
@@ -817,7 +827,8 @@ export default function GeneralPage() {
)
? formatPostureValue(
client.posture
.linuxSELinuxEnabled
.linuxSELinuxEnabled ===
true
)
: "-"}
</InfoSectionContent>
+49 -33
View File
@@ -1,14 +1,16 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { 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<Record<string, string>>;
};
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<AxiosResponse<ListClientsResponse>>(
`/org/${params.orgId}/clients?filter=user`,
const userRes = await internal.get<
AxiosResponse<ListUserDevicesResponse>
>(
`/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) {
<UserDevicesTable
userClients={userClientRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
@@ -14,7 +14,7 @@ import { redirect } from "next/navigation";
export interface ClientResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
searchParams: Promise<Record<string, string>>;
}
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<AxiosResponse<ListResourcesResponse>>(
`/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<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
>(
`/org/${params.orgId}/site-resources?${searchParams.toString()}`,
await authCookieHeader()
);
const responseData = res.data.data;
siteResources = responseData.siteResources;
pagination = responseData.pagination;
} catch (e) {}
let org = null;
@@ -89,9 +91,10 @@ export default async function ClientResourcesPage(
<ClientResourcesTable
internalResources={internalResourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider>
@@ -16,7 +16,7 @@ import { cache } from "react";
export interface ProxyResourcesPageProps {
params: Promise<{ orgId: string }>;
searchParams: Promise<{ view?: string }>;
searchParams: Promise<Record<string, string>>;
}
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<AxiosResponse<ListResourcesResponse>>(
`/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(
<ProxyResourcesTable
resources={resourceRows}
orgId={params.orgId}
defaultSort={{
id: "name",
desc: false
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</OrgProvider>
+22 -5
View File
@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
type SitesPageProps = {
params: Promise<{ orgId: string }>;
searchParams: Promise<Record<string, string>>;
};
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<AxiosResponse<ListSitesResponse>>(
`/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 (
<>
{/* <SitesSplashCard /> */}
<SettingsSectionTitle
title={t("siteManageSites")}
description={t("siteDescription")}
@@ -69,7 +78,15 @@ export default async function SitesPage(props: SitesPageProps) {
<SitesBanner />
<SitesTable sites={siteRows} orgId={params.orgId} />
<SitesTable
sites={siteRows}
orgId={params.orgId}
rowCount={pagination.total}
pagination={{
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
/>
</>
);
}
@@ -26,6 +26,7 @@ import type {
import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
export const dynamic = "force-dynamic";
@@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: {
} catch (e) {}
}
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
if (normalizedPostAuthPath) {
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
}
const hasAuth =
authInfo.password ||
authInfo.pincode ||
+34 -9
View File
@@ -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"
>
<RefreshCw
className={cn(
"size-4",
isFetching && "animate-spin"
(isFetching || isLoading) && "animate-spin"
)}
/>
{t("refresh")}
@@ -145,13 +153,30 @@ export function ApprovalFeed({
))}
{approvals.length === 0 && (
<li className="flex justify-center items-center p-4 text-muted-foreground">
{t("approvalListEmpty")}
<li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
{isLoading
? t("loadingApprovals")
: t("approvalListEmpty")}
{isLoading && (
<Loader className="size-4 flex-none animate-spin" />
)}
</li>
)}
</ul>
</CardHeader>
</Card>
{hasNextPage && (
<Button
variant="secondary"
className="self-center"
size="lg"
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
>
{t("approvalLoadMore")}
</Button>
)}
</div>
);
}
+53 -16
View File
@@ -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 }) => (
<FormItem className="md:col-span-3">
<FormLabel>
{t("brandingLogoURL")}
{build === "enterprise"
? t(
"brandingLogoURLOrPath"
)
: t("brandingLogoURL")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{build === "enterprise"
? t(
"brandingLogoPathDescription"
)
: t(
"brandingLogoURLDescription"
)}
</FormDescription>
</FormItem>
)}
/>
+73 -29
View File
@@ -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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="p-3">{t("name")}</span>
},
{
id: "niceId",
@@ -180,9 +177,24 @@ export default function ClientResourcesTable({
accessorKey: "mode",
friendlyName: t("editInternalResourceDialogMode"),
header: () => (
<span className="p-3">
{t("editInternalResourceDialogMode")}
</span>
<ColumnFilterButton
options={[
{
value: "host",
label: t("editInternalResourceDialogModeHost")
},
{
value: "cidr",
label: t("editInternalResourceDialogModeCidr")
}
]}
selectedValue={searchParams.get("mode") ?? undefined}
onValueChange={(value) => handleFilterChange("mode", value)}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("editInternalResourceDialogMode")}
className="p-3"
/>
),
cell: ({ row }) => {
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({
/>
)}
<DataTable
<ControlledDataTable
columns={internalColumns}
data={internalResources}
persistPageSize="internal-resources"
rows={internalResources}
tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() => 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
+10 -6
View File
@@ -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({
>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<span className="truncate">
{selectedOption
? selectedOption.label
: placeholder}
</span>
{selectedOption && (
<Badge className="truncate" variant="secondary">
{selectedOption
? selectedOption.label
: placeholder}
</Badge>
)}
</div>
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<PopoverContent className="p-0 w-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { Badge } from "./ui/badge";
interface FilterOption {
value: string;
label: string;
}
interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
label: string;
}
export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
}: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
const selectedOption = options.find(
(option) => option.value === selectedValue
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
!selectedValue && "text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2">
{label}
<Funnel className="size-4 flex-none" />
{selectedOption && (
<Badge className="truncate" variant="secondary">
{selectedOption.label}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-50" align="start">
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{/* Clear filter option */}
{selectedValue && (
<CommandItem
onSelect={() => {
onValueChange(undefined);
setOpen(false);
}}
className="text-muted-foreground"
>
Clear filter
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
onValueChange(
selectedValue === option.value
? undefined
: option.value
);
setOpen(false);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -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
})
);
@@ -303,7 +300,7 @@ export default function CreateInternalResourceDialog({
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({
@@ -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 }),
@@ -397,7 +394,7 @@ export default function EditInternalResourceDialog({
);
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
(site) => site.type === "newt"
);
const form = useForm<FormData>({
+1 -1
View File
@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
</Button>
</Link>
<Link
href="https://docs.pangolin.net/manage/clients/install-client#docker"
href="https://docs.pangolin.net/manage/clients/install-client#docker-pangolin-cli"
target="_blank"
rel="noopener noreferrer"
>
+150 -126
View File
@@ -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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
friendlyName: t("name"),
header: () => <span className="px-3">{t("name")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
{
accessorKey: "niceId",
friendlyName: "Identifier",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="px-3">{t("identifier")}</span>
},
{
accessorKey: "online",
friendlyName: "Connectivity",
header: ({ column }) => {
friendlyName: t("online"),
header: () => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
<ColumnFilterButton
options={[
{
value: "true",
label: t("connected")
},
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
}
>
Connectivity
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
onClick={() => toggleSort("megabytesIn")}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
{t("dataIn")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
onClick={() => toggleSort("megabytesOut")}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
{t("dataOut")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
@@ -318,21 +336,7 @@ export default function MachineClientsTable({
{
accessorKey: "client",
friendlyName: t("agent"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("agent")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => <span className="px-3">{t("agent")}</span>,
cell: ({ row }) => {
const originalRow = row.original;
@@ -356,22 +360,8 @@ export default function MachineClientsTable({
},
{
accessorKey: "subnet",
friendlyName: "Address",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span>
}
];
@@ -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"
/>
)}
<DataTable
<ControlledDataTable
columns={columns}
data={machineClients || []}
persistPageSize="machine-clients"
rows={machineClients}
tableId="machine-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onAdd={() =>
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")
}
]}
/>
+1 -1
View File
@@ -83,7 +83,7 @@ export function OrgSelector({
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t("searchProgress")}
placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">
+195 -97
View File
@@ -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<ResourceRow | null>();
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<AxiosResponse<UpdateResourceResponse>>(
try {
await api.post<AxiosResponse<UpdateResourceResponse>>(
`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({
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[280px]">
<DropdownMenuContent align="start" className="min-w-70">
{monitoredTargets.length > 0 && (
<>
{monitoredTargets.map((target) => (
@@ -302,38 +318,14 @@ export default function ProxyResourcesTable({
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="p-3">{t("name")}</span>
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => <span className="p-3">{t("identifier")}</span>,
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
}
@@ -359,19 +351,33 @@ export default function ProxyResourcesTable({
id: "status",
accessorKey: "status",
friendlyName: t("status"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => (
<ColumnFilterButton
options={[
{ value: "healthy", label: t("resourcesTableHealthy") },
{
value: "degraded",
label: t("resourcesTableDegraded")
},
{ value: "offline", label: t("resourcesTableOffline") },
{
value: "no_targets",
label: t("resourcesTableNoTargets")
},
{ value: "unknown", label: t("resourcesTableUnknown") }
]}
selectedValue={
searchParams.get("healthStatus") ?? undefined
}
onValueChange={(value) =>
handleFilterChange("healthStatus", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("status")}
className="p-3"
/>
),
cell: ({ row }) => {
const resourceRow = row.original;
return <TargetStatusCell targets={resourceRow.targets} />;
@@ -419,19 +425,23 @@ export default function ProxyResourcesTable({
{
accessorKey: "authState",
friendlyName: t("authentication"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("authentication")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => (
<ColumnFilterButton
options={[
{ value: "protected", label: t("protected") },
{ value: "not_protected", label: t("notProtected") },
{ value: "none", label: t("none") }
]}
selectedValue={searchParams.get("authState") ?? undefined}
onValueChange={(value) =>
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: () => <span className="p-3">{t("enabled")}</span>,
header: () => (
<ColumnFilterButton
options={[
{ value: "true", label: t("enabled") },
{ value: "false", label: t("disabled") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("enabled")
)}
onValueChange={(value) =>
handleFilterChange("enabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("enabled")}
className="p-3"
/>
),
cell: ({ row }) => (
<Switch
defaultChecked={
row.original.http
? !!row.original.domainId && row.original.enabled
: row.original.enabled
}
disabled={
row.original.http ? !row.original.domainId : false
}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
<ResourceEnabledForm
resource={row.original}
onToggleResourceEnabled={toggleResourceEnabled}
/>
)
},
@@ -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({
/>
)}
<DataTable
<ControlledDataTable
columns={proxyColumns}
data={resources}
persistPageSize="proxy-resources"
rows={resources}
tableId="proxy-resources"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
pagination={pagination}
rowCount={rowCount}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
onAdd={() =>
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<void>;
};
function ResourceEnabledForm({
resource,
onToggleResourceEnabled
}: ResourceEnabledFormProps) {
const enabled = resource.http
? !!resource.domainId && resource.enabled
: resource.enabled;
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled);
const formRef = useRef<ComponentRef<"form">>(null);
async function submitAction(formData: FormData) {
const newEnabled = !(formData.get("enabled") === "on");
setOptimisticEnabled(newEnabled);
await onToggleResourceEnabled(newEnabled, resource.id);
}
return (
<form action={submitAction} ref={formRef}>
<Switch
checked={optimisticEnabled}
disabled={
(resource.http && !resource.domainId) ||
optimisticEnabled !== enabled
}
name="enabled"
onCheckedChange={() => formRef.current?.requestSubmit()}
/>
</form>
);
}
-50
View File
@@ -1,50 +0,0 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
createSite?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
}
export function SitesDataTable<TData, TValue>({
columns,
data,
createSite,
onRefresh,
isRefreshing,
columnVisibility,
enableColumnVisibility
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
persistPageSize="sites-table"
title={t("sites")}
searchPlaceholder={t("searchSitesProgress")}
searchColumn="name"
onAdd={createSite}
addButtonText={t("siteAdd")}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
defaultSort={{
id: "name",
desc: false
}}
columnVisibility={columnVisibility}
enableColumnVisibility={enableColumnVisibility}
stickyLeftColumn="name"
stickyRightColumn="actions"
/>
);
}
+192 -158
View File
@@ -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<SiteRow | null>(null);
const [rows, setRows] = useState<SiteRow[]>(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<SiteRow>[] = [
{
accessorKey: "name",
enableHiding: false,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("name")}</span>;
}
},
{
@@ -132,18 +149,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("identifier")}</span>;
},
cell: ({ row }) => {
return <span>{row.original.nice || "-"}</span>;
@@ -152,17 +159,24 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
{
accessorKey: "online",
friendlyName: t("online"),
header: ({ column }) => {
header: () => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
<ColumnFilterButton
options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
handleFilterChange("online", value)
}
>
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
onClick={() => toggleSort("megabytesIn")}
>
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
onClick={() => toggleSort("megabytesOut")}
>
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
},
sortingFn: (rowA, rowB) =>
parseDataSize(rowA.original.mbOut) -
parseDataSize(rowB.original.mbOut)
}
},
{
accessorKey: "type",
friendlyName: t("type"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("type")}</span>;
},
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("exitNode")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
@@ -354,18 +359,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
accessorKey: "address",
header: ({ column }: { column: Column<SiteRow, unknown> }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Address
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
header: () => {
return <span className="p-3">{t("address")}</span>;
},
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) {
</div>
}
buttonText={t("siteConfirmDelete")}
onConfirm={async () => deleteSite(selectedSite!.id)}
onConfirm={async () =>
startTransition(() => deleteSite(selectedSite!.id))
}
string={selectedSite.name}
title={t("siteDelete")}
/>
)}
<SitesDataTable
<ControlledDataTable
columns={columns}
data={rows}
createSite={() =>
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"
/>
</>
);
+9 -6
View File
@@ -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 }
+233 -178
View File
@@ -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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => <span className="px-3">{t("name")}</span>,
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("identifier")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="px-3">{t("identifier")}</span>
},
{
accessorKey: "userEmail",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
header: () => <span className="px-3">{t("users")}</span>,
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
<ColumnFilterButton
options={[
{
value: "true",
label: t("connected")
},
{
value: "false",
label: t("disconnected")
}
]}
selectedValue={
searchParams.get("online") ?? undefined
}
>
{t("online")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
onClick={() => toggleSort("megabytesIn")}
>
{t("dataIn")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
@@ -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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
onClick={() => toggleSort("megabytesOut")}
>
{t("dataOut")}
<ArrowUpDown className="ml-2 h-4 w-4" />
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
@@ -474,21 +489,52 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
{
accessorKey: "client",
friendlyName: t("agent"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
header: () => (
<ColumnFilterButton
options={[
{
value: "macos",
label: "Pangolin macOS"
},
{
value: "ios",
label: "Pangolin iOS"
},
{
value: "ipados",
label: "Pangolin iPadOS"
},
{
value: "android",
label: "Pangolin Android"
},
{
value: "windows",
label: "Pangolin Windows"
},
{
value: "cli",
label: "Pangolin CLI"
},
{
value: "olm",
label: "Olm CLI"
},
{
value: "unknown",
label: t("unknown")
}
>
{t("agent")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
]}
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 (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(
column.getIsSorted() === "asc"
)
}
>
{t("address")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
header: () => <span className="px-3">{t("address")}</span>
}
];
@@ -548,20 +580,25 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{clientRow.approvalState === "pending" && (
<>
<DropdownMenuItem
onClick={() => approveDevice(clientRow)}
>
<span>{t("approve")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => denyDevice(clientRow)}
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
{clientRow.approvalState === "pending" &&
build !== "oss" && (
<>
<DropdownMenuItem
onClick={() =>
approveDevice(clientRow)
}
>
<span>{t("approve")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
denyDevice(clientRow)
}
>
<span>{t("deny")}</span>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
onClick={() => {
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) {
)}
<ClientDownloadBanner />
<DataTable
<ControlledDataTable
columns={columns}
data={userClients || []}
persistPageSize="user-clients"
rows={userClients || []}
tableId="user-clients"
searchPlaceholder={t("resourcesSearch")}
searchColumn="name"
onRefresh={refreshData}
isRefreshing={isRefreshing}
enableColumnVisibility={true}
persistColumnVisibility="user-clients"
isRefreshing={isRefreshing || isFiltering}
enableColumnVisibility
columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange}
pagination={pagination}
rowCount={rowCount}
stickyLeftColumn="name"
stickyRightColumn="actions"
filters={[
@@ -702,41 +788,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
multiSelect: true,
displayMode: "calculated",
options: statusFilterOptions,
filterFn: (
row: ClientRow,
selectedValues: (string | number | boolean)[]
) => {
if (selectedValues.length === 0) return true;
const rowArchived = row.archived;
const rowBlocked = row.blocked;
const approvalState = row.approvalState;
const isActive = !rowArchived && !rowBlocked && approvalState !== "pending" && approvalState !== "denied";
if (selectedValues.includes("active") && isActive)
return true;
if (
selectedValues.includes("pending") &&
approvalState === "pending"
)
return true;
if (
selectedValues.includes("denied") &&
approvalState === "denied"
)
return true;
if (
selectedValues.includes("archived") &&
rowArchived
)
return true;
if (
selectedValues.includes("blocked") &&
rowBlocked
)
return true;
return false;
onValueChange: (selectedValues: string[]) => {
handleFilterChange("status", selectedValues);
},
defaultValues: statusFilterDefaultValues
values: searchParams.getAll("status")
}
]}
/>
+2 -2
View File
@@ -18,11 +18,11 @@ export type CommandItem = string | { title: string; command: string };
const PLATFORMS = [
"unix",
"windows",
"docker",
"kubernetes",
"podman",
"nixos"
"nixos",
"windows"
] as const;
type Platform = (typeof PLATFORMS)[number];
+21 -20
View File
@@ -14,7 +14,7 @@ import { Button } from "./ui/button";
export type CommandItem = string | { title: string; command: string };
const PLATFORMS = ["unix", "windows", "docker"] as const;
const PLATFORMS = ["unix", "docker", "windows"] as const;
type Platform = (typeof PLATFORMS)[number];
@@ -43,7 +43,7 @@ export function OlmInstallCommands({
All: [
{
title: t("install"),
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | bash`
command: `curl -fsSL https://static.pangolin.net/get-cli.sh | sudo bash`
},
{
title: t("run"),
@@ -51,24 +51,12 @@ export function OlmInstallCommands({
}
]
},
windows: {
x64: [
{
title: t("install"),
command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
},
docker: {
"Docker Compose": [
`services:
olm:
image: fosrl/olm
container_name: olm
pangolin-cli:
image: fosrl/pangolin-cli
container_name: pangolin-cli
restart: unless-stopped
network_mode: host
cap_add:
@@ -77,11 +65,24 @@ export function OlmInstallCommands({
- /dev/net/tun:/dev/net/tun
environment:
- PANGOLIN_ENDPOINT=${endpoint}
- OLM_ID=${id}
- OLM_SECRET=${secret}`
- CLIENT_ID=${id}
- CLIENT_SECRET=${secret}`
],
"Docker Run": [
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/olm --id ${id} --secret ${secret} --endpoint ${endpoint}`
`docker run -dit --network host --cap-add NET_ADMIN --device /dev/net/tun:/dev/net/tun fosrl/pangolin-cli up client --id ${id} --secret ${secret} --endpoint ${endpoint} --attach`
]
},
windows: {
x64: [
{
title: t("install"),
command: `# Download and run the installer to install Olm first\n
curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"`
},
{
title: t("run"),
command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
}
]
}
};
@@ -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];
+592
View File
@@ -0,0 +1,592 @@
"use client";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
PaginationState,
useReactTable
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Input } from "@app/components/ui/input";
import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility";
import { Columns, Filter, Plus, RefreshCw, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { useMemo, useState } from "react";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
export type ExtendedColumnDef<TData, TValue = unknown> = ColumnDef<
TData,
TValue
> & {
friendlyName?: string;
};
type FilterOption = {
id: string;
label: string;
value: string;
};
type DataTableFilter = {
id: string;
label: string;
options: FilterOption[];
multiSelect?: boolean;
onValueChange: (selectedValues: string[]) => void;
values?: string[];
displayMode?: "label" | "calculated"; // How to display the filter button text
};
export type DataTablePaginationUpdateFn = (newPage: PaginationState) => void;
type ControlledDataTableProps<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
rows: TData[];
tableId: string;
addButtonText?: string;
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
isNavigatingToAddPage?: boolean;
searchPlaceholder?: string;
filters?: DataTableFilter[];
filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter)
columnVisibility?: Record<string, boolean>;
enableColumnVisibility?: boolean;
onSearch?: (input: string) => void;
searchQuery?: string;
onPaginationChange: DataTablePaginationUpdateFn;
stickyLeftColumn?: string; // Column ID or accessorKey for left sticky column
stickyRightColumn?: string; // Column ID or accessorKey for right sticky column (typically "actions")
rowCount: number;
pagination: PaginationState;
};
export function ControlledDataTable<TData, TValue>({
columns,
rows,
addButtonText,
onAdd,
onRefresh,
isRefreshing,
searchPlaceholder = "Search...",
filters,
filterDisplayMode = "label",
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
tableId,
pagination,
stickyLeftColumn,
onSearch,
searchQuery,
onPaginationChange,
stickyRightColumn,
rowCount,
isNavigatingToAddPage
}: ControlledDataTableProps<TData, TValue>) {
const t = useTranslations();
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useStoredColumnVisibility(
tableId,
defaultColumnVisibility
);
// TODO: filters
const activeFilters = useMemo(() => {
const initial: Record<string, string[]> = {};
filters?.forEach((filter) => {
initial[filter.id] = filter.values || [];
});
return initial;
}, [filters]);
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
// getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: (state) => {
const newState =
typeof state === "function" ? state(pagination) : state;
onPaginationChange(newState);
},
manualFiltering: true,
manualPagination: true,
rowCount,
state: {
columnFilters,
columnVisibility,
pagination
}
});
// Calculate display text for a filter based on selected values
const getFilterDisplayText = (filter: DataTableFilter): string => {
const selectedValues = activeFilters[filter.id] || [];
if (selectedValues.length === 0) {
return filter.label;
}
const selectedOptions = filter.options.filter((option) =>
selectedValues.includes(option.value)
);
if (selectedOptions.length === 0) {
return filter.label;
}
if (selectedOptions.length === 1) {
return selectedOptions[0].label;
}
// Multiple selections: always join with "and"
return selectedOptions.map((opt) => opt.label).join(" or ");
};
const handleFilterChange = (
filterId: string,
optionValue: string,
checked: boolean
) => {
const currentValues = activeFilters[filterId] || [];
const filter = filters?.find((f) => f.id === filterId);
if (!filter) return;
let newValues: string[];
if (filter.multiSelect) {
// Multi-select: add or remove the value
if (checked) {
newValues = [...currentValues, optionValue];
} else {
newValues = currentValues.filter((v) => v !== optionValue);
}
} else {
// Single-select: replace the value
newValues = checked ? [optionValue] : [];
}
filter.onValueChange(newValues);
};
// Helper function to check if a column should be sticky
const isStickyColumn = (
columnId: string | undefined,
accessorKey: string | undefined,
position: "left" | "right"
): boolean => {
if (position === "left" && stickyLeftColumn) {
return (
columnId === stickyLeftColumn ||
accessorKey === stickyLeftColumn
);
}
if (position === "right" && stickyRightColumn) {
return (
columnId === stickyRightColumn ||
accessorKey === stickyRightColumn
);
}
return false;
};
// Get sticky column classes
const getStickyClasses = (
columnId: string | undefined,
accessorKey: string | undefined
): string => {
if (isStickyColumn(columnId, accessorKey, "left")) {
return "md:sticky md:left-0 z-10 bg-card [mask-image:linear-gradient(to_left,transparent_0%,black_20px)]";
}
if (isStickyColumn(columnId, accessorKey, "right")) {
return "sticky right-0 z-10 w-auto min-w-fit bg-card [mask-image:linear-gradient(to_right,transparent_0%,black_20px)]";
}
return "";
};
return (
<div className="container mx-auto max-w-12xl">
<Card>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="flex flex-row space-y-3 w-full sm:mr-2 gap-2">
{onSearch && (
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
defaultValue={searchQuery}
onChange={(e) =>
onSearch(e.currentTarget.value)
}
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
</div>
)}
{filters && filters.length > 0 && (
<div className="flex gap-2">
{filters.map((filter) => {
const selectedValues =
activeFilters[filter.id] || [];
const hasActiveFilters =
selectedValues.length > 0;
const displayMode =
filter.displayMode || filterDisplayMode;
const displayText =
displayMode === "calculated"
? getFilterDisplayText(filter)
: filter.label;
return (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
<Button
variant={"outline"}
size="sm"
className="h-9"
>
<Filter className="h-4 w-4 mr-2" />
{displayText}
{displayMode === "label" &&
hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{
selectedValues.length
}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-48"
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map(
(option) => {
const isChecked =
selectedValues.includes(
option.value
);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={
isChecked
}
onCheckedChange={(
checked
) => {
handleFilterChange(
filter.id,
option.value,
checked
);
}}
onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<div>
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw
className={`mr-0 sm:mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="hidden sm:inline">
{t("refresh")}
</span>
</Button>
</div>
)}
{onAdd && addButtonText && (
<div>
<Button
onClick={onAdd}
loading={isNavigatingToAddPage}
>
<Plus className="mr-2 h-4 w-4" />
{addButtonText}
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const columnId = header.column.id;
const accessorKey = (
header.column.columnDef as any
).accessorKey as string | undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
const hasHideableColumns =
enableColumnVisibility &&
table
.getAllColumns()
.some((col) =>
col.getCanHide()
);
return (
<TableHead
key={header.id}
className={`whitespace-nowrap ${stickyClasses}`}
>
{header.isPlaceholder ? null : isRightSticky &&
hasHideableColumns ? (
<div className="flex flex-col items-end pr-3">
<DropdownMenu>
<DropdownMenuTrigger
asChild
>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 mb-1"
>
<Columns className="h-4 w-4" />
<span className="sr-only">
{t(
"columns"
) ||
"Columns"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-48"
>
<DropdownMenuLabel>
{t(
"toggleColumns"
) ||
"Toggle columns"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(
column
) =>
column.getCanHide()
)
.map(
(
column
) => {
const columnDef =
column.columnDef as any;
const friendlyName =
columnDef.friendlyName;
const displayName =
friendlyName ||
(typeof columnDef.header ===
"string"
? columnDef.header
: column.id);
return (
<DropdownMenuCheckboxItem
key={
column.id
}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(
value
) =>
column.toggleVisibility(
!!value
)
}
onSelect={(
e
) =>
e.preventDefault()
}
>
{
displayName
}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="h-0 opacity-0 pointer-events-none overflow-hidden">
{flexRender(
header
.column
.columnDef
.header,
header.getContext()
)}
</div>
</div>
) : (
flexRender(
header.column
.columnDef
.header,
header.getContext()
)
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
"selected"
}
>
{row
.getVisibleCells()
.map((cell) => {
const columnId =
cell.column.id;
const accessorKey = (
cell.column
.columnDef as any
).accessorKey as
| string
| undefined;
const stickyClasses =
getStickyClasses(
columnId,
accessorKey
);
const isRightSticky =
isStickyColumn(
columnId,
accessorKey,
"right"
);
return (
<TableCell
key={cell.id}
className={`whitespace-nowrap ${stickyClasses} ${isRightSticky ? "text-right" : ""}`}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="mt-4">
{rowCount > 0 && (
<DataTablePagination
table={table}
totalCount={rowCount}
onPageSizeChange={(pageSize) =>
onPaginationChange({
...pagination,
pageSize
})
}
onPageChange={(pageIndex) => {
onPaginationChange({
...pagination,
pageIndex
});
}}
isServerPagination
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
)}
</div>
</CardContent>
</Card>
</div>
);
}
+116 -62
View File
@@ -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<TData, TValue> = {
columns: ExtendedColumnDef<TData, TValue>[];
data: TData[];
@@ -178,6 +187,11 @@ type DataTableProps<TData, TValue> = {
defaultPageSize?: number;
columnVisibility?: Record<string, boolean>;
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<TData, TValue>({
columnVisibility: defaultColumnVisibility,
enableColumnVisibility = false,
persistColumnVisibility = false,
manualFiltering = false,
pagination: paginationState,
stickyLeftColumn,
onSearch,
searchQuery,
onPaginationChange,
stickyRightColumn
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
@@ -248,22 +267,25 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility
);
const [pagination, setPagination] = useState<PaginationState>({
const [_pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: pageSize
});
const pagination = paginationState ?? _pagination;
const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || ""
);
const [activeFilters, setActiveFilters] = useState<Record<string, (string | number | boolean)[]>>(
() => {
const initial: Record<string, (string | number | boolean)[]> = {};
filters?.forEach((filter) => {
initial[filter.id] = filter.defaultValues || [];
});
return initial;
}
);
const [activeFilters, setActiveFilters] = useState<
Record<string, (string | number | boolean)[]>
>(() => {
const initial: Record<string, (string | number | boolean)[]> = {};
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<TData, TValue>({
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<TData, TValue>({
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<TData, TValue>({
// 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<TData, TValue>({
<div className="relative w-full sm:max-w-sm">
<Input
placeholder={searchPlaceholder}
value={globalFilter ?? ""}
onChange={(e) =>
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"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2 text-muted-foreground" />
@@ -490,13 +521,17 @@ export function DataTable<TData, TValue>({
{filters && filters.length > 0 && (
<div className="flex gap-2">
{filters.map((filter) => {
const selectedValues = activeFilters[filter.id] || [];
const hasActiveFilters = selectedValues.length > 0;
const displayMode = filter.displayMode || filterDisplayMode;
const displayText = displayMode === "calculated"
? getFilterDisplayText(filter)
: filter.label;
const selectedValues =
activeFilters[filter.id] || [];
const hasActiveFilters =
selectedValues.length > 0;
const displayMode =
filter.displayMode || filterDisplayMode;
const displayText =
displayMode === "calculated"
? getFilterDisplayText(filter)
: filter.label;
return (
<DropdownMenu key={filter.id}>
<DropdownMenuTrigger asChild>
@@ -507,37 +542,54 @@ export function DataTable<TData, TValue>({
>
<Filter className="h-4 w-4 mr-2" />
{displayText}
{displayMode === "label" && hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{selectedValues.length}
</span>
)}
{displayMode === "label" &&
hasActiveFilters && (
<span className="ml-2 bg-muted text-foreground rounded-full px-2 py-0.5 text-xs">
{
selectedValues.length
}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuContent
align="start"
className="w-48"
>
<DropdownMenuLabel>
{filter.label}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{filter.options.map((option) => {
const isChecked = selectedValues.includes(option.value);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={isChecked}
onCheckedChange={(checked) =>
handleFilterChange(
filter.id,
option.value,
{filter.options.map(
(option) => {
const isChecked =
selectedValues.includes(
option.value
);
return (
<DropdownMenuCheckboxItem
key={option.id}
checked={
isChecked
}
onCheckedChange={(
checked
)
}
onSelect={(e) => e.preventDefault()}
>
{option.label}
</DropdownMenuCheckboxItem>
);
})}
) =>
handleFilterChange(
filter.id,
option.value,
checked
)
}
onSelect={(e) =>
e.preventDefault()
}
>
{option.label}
</DropdownMenuCheckboxItem>
);
}
)}
</DropdownMenuContent>
</DropdownMenu>
);
@@ -795,12 +847,14 @@ export function DataTable<TData, TValue>({
</Table>
</div>
<div className="mt-4">
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
{table.getRowModel().rows?.length > 0 && (
<DataTablePagination
table={table}
onPageSizeChange={handlePageSizeChange}
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/>
)}
</div>
</CardContent>
</Card>
+36
View File
@@ -0,0 +1,36 @@
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useTransition } from "react";
export function useNavigationContext() {
const router = useRouter();
const searchParams = useSearchParams();
const path = usePathname();
const [isNavigating, startTransition] = useTransition();
function navigate({
searchParams: params,
pathname = path,
replace = false
}: {
pathname?: string;
searchParams?: URLSearchParams;
replace?: boolean;
}) {
startTransition(() => {
const fullPath = pathname + (params ? `?${params.toString()}` : "");
if (replace) {
router.replace(fullPath);
} else {
router.push(fullPath);
}
});
}
return {
pathname: path,
searchParams: new URLSearchParams(searchParams), // we want the search params to be writeable
navigate,
isNavigating
};
}
+51 -17
View File
@@ -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<typeof clientFilterSchema>;
filters?: z.infer<typeof clientFilterSchema>;
}) =>
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<typeof logAnalyticsFiltersSchema>;
@@ -361,22 +361,50 @@ export const approvalQueries = {
orgId: string,
filters: z.infer<typeof approvalFiltersSchema>
) =>
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;
}
})
};
+52
View File
@@ -0,0 +1,52 @@
import type { SortOrder } from "@app/lib/types/sort";
export function getNextSortOrder(
column: string,
searchParams: URLSearchParams
) {
const sp = new URLSearchParams(searchParams);
let nextDirection: SortOrder = "indeterminate";
if (sp.get("sort_by") === column) {
nextDirection = (sp.get("order") as SortOrder) ?? "indeterminate";
}
switch (nextDirection) {
case "indeterminate": {
nextDirection = "asc";
break;
}
case "asc": {
nextDirection = "desc";
break;
}
default: {
nextDirection = "indeterminate";
break;
}
}
sp.delete("sort_by");
sp.delete("order");
if (nextDirection !== "indeterminate") {
sp.set("sort_by", column);
sp.set("order", nextDirection);
}
return sp;
}
export function getSortDirection(
column: string,
searchParams: URLSearchParams
) {
let currentDirection: SortOrder = "indeterminate";
if (searchParams.get("sort_by") === column) {
currentDirection =
(searchParams.get("order") as SortOrder) ?? "indeterminate";
}
return currentDirection;
}
+1
View File
@@ -0,0 +1 @@
export type SortOrder = "asc" | "desc" | "indeterminate";
+16
View File
@@ -0,0 +1,16 @@
export function validateLocalPath(value: string) {
try {
const url = new URL("https://pangoling.net" + value);
if (
url.pathname !== value ||
value.includes("..") ||
value.includes("*")
) {
throw new Error("Invalid Path");
}
} catch {
throw new Error(
"should be a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
);
}
}