mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-30 07:51:53 +02:00
Compare commits
126 Commits
1.15.3
...
8315d4b6ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 8315d4b6ae | |||
| d00262dc31 | |||
| 3debc6c8d3 | |||
| 5092eb58fb | |||
| f0b9240575 | |||
| 9cf59c409e | |||
| bfd5aa30a7 | |||
| 9737170665 | |||
| 922a040466 | |||
| 33f0782f3a | |||
| e6a5cef945 | |||
| 4c8edb80b3 | |||
| d4668fae99 | |||
| ddfe55e3ae | |||
| 761a5f1d4c | |||
| 1fbcad8787 | |||
| aba586e605 | |||
| 27b21b5ad4 | |||
| b6e54dab17 | |||
| 1f8e89772d | |||
| be89e5ca55 | |||
| 5f3657fd56 | |||
| 494162400e | |||
| ab65bb6a8a | |||
| 333625f199 | |||
| dbfd715381 | |||
| f1d989964e | |||
| b701629498 | |||
| 8250946325 | |||
| 71f63d8e6f | |||
| dd5e834db0 | |||
| 970ecb52f0 | |||
| 62ea1b40e1 | |||
| 3b0fd5c592 | |||
| b7616026dd | |||
| 16ad60b89a | |||
| db7971d2f7 | |||
| f3f8bd3125 | |||
| 516fd0ee8f | |||
| 8d6700d493 | |||
| 9d4ace9b3e | |||
| 2800655e33 | |||
| 91eecee11d | |||
| 899e5aa395 | |||
| d5820c4902 | |||
| a91c002274 | |||
| 4d142b93dd | |||
| 04dcf57ff3 | |||
| 975550c755 | |||
| a964a80d85 | |||
| 22c3b8f116 | |||
| c4b1831cfe | |||
| cdb6813384 | |||
| b14b68d83c | |||
| 3c2f930e6b | |||
| ca9c7ce555 | |||
| c2e95a0607 | |||
| 2767ee9e80 | |||
| d998a8087f | |||
| fdce016921 | |||
| c73d70933b | |||
| e9d0ad6e37 | |||
| a35586f762 | |||
| f527c30923 | |||
| 94e70219cf | |||
| 6496763aae | |||
| a409ec269b | |||
| bc7bc8da66 | |||
| 52484c774e | |||
| 4e1e0cade1 | |||
| fda5904dac | |||
| 69ecc22318 | |||
| bff9d33ee6 | |||
| 6d1665004b | |||
| 59b8119fbd | |||
| 45cd4df6e5 | |||
| d5b6de70da | |||
| d6ade102dc | |||
| c94d246c24 | |||
| 5b779ba9fe | |||
| 3ba2cb19a9 | |||
| da514ef314 | |||
| 7f73cde794 | |||
| b0af0d9cd5 | |||
| 8429197b07 | |||
| 44f2081882 | |||
| 63f7dd1d20 | |||
| 57b8c69983 | |||
| aad060810a | |||
| 9222b00a6f | |||
| ff61b22e7e | |||
| 577cb91343 | |||
| 1889386f64 | |||
| 5d7f082ebf | |||
| db6327c4ff | |||
| fd7f6b2b99 | |||
| 49435398a8 | |||
| 9f2fd34e99 | |||
| 67b63d3084 | |||
| 4a31a7b84b | |||
| 538b601b1e | |||
| 588f064c25 | |||
| d521e79662 | |||
| ccddb9244d | |||
| 0547396213 | |||
| 6c85171091 | |||
| 0f4d1d2a74 | |||
| 941d5c08e3 | |||
| db9f74158b | |||
| 609ffccd67 | |||
| 748af1d8cb | |||
| d309ec249e | |||
| 67949b4968 | |||
| 1fc40b3017 | |||
| bb1a375484 | |||
| 13c011895d | |||
| bd8d0e3392 | |||
| cda6b67bef | |||
| 066305b095 | |||
| 89695df012 | |||
| b04385a340 | |||
| d374ea6ea6 | |||
| 01a2820390 | |||
| c89c1a03da | |||
| 38ac4c5980 | |||
| ed3ee64e4b |
@@ -32,4 +32,5 @@ migrations/
|
|||||||
config/
|
config/
|
||||||
build.ts
|
build.ts
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
|
Dockerfile*
|
||||||
migrations/
|
migrations/
|
||||||
|
|||||||
@@ -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.
|
# 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.
|
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
run: npm run db:generate
|
run: npm run db:generate
|
||||||
|
|
||||||
- name: Apply database migrations
|
- name: Apply database migrations
|
||||||
run: npm run db:sqlite:push
|
run: npm run db:push
|
||||||
|
|
||||||
- name: Test with tsc
|
- name: Test with tsc
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|||||||
@@ -52,3 +52,5 @@ scratch/
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
hydrateSaas.ts
|
hydrateSaas.ts
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
drizzle.config.ts
|
||||||
|
server/setup/migrations.ts
|
||||||
|
|||||||
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
|||||||
+32
-33
@@ -1,33 +1,54 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24-alpine AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG BUILD=oss
|
|
||||||
ARG DATABASE=sqlite
|
|
||||||
|
|
||||||
RUN apk add --no-cache python3 make g++
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
FROM base AS builder-dev
|
||||||
|
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ARG BUILD=oss
|
||||||
|
ARG DATABASE=sqlite
|
||||||
|
|
||||||
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
|
||||||
npm run set:$DATABASE && \
|
npm run set:$DATABASE && \
|
||||||
npm run set:$BUILD && \
|
npm run set:$BUILD && \
|
||||||
npm run db:$DATABASE:generate && \
|
npm run db:generate && \
|
||||||
npm run build && \
|
npm run build && \
|
||||||
npm run build:cli
|
npm run build:cli && \
|
||||||
|
test -f dist/server.mjs
|
||||||
|
|
||||||
# test to make sure the build output is there and error if not
|
FROM base AS builder
|
||||||
RUN test -f dist/server.mjs
|
|
||||||
|
|
||||||
# Prune dev dependencies and clean up to prepare for copy to runner
|
RUN npm ci --omit=dev
|
||||||
RUN npm prune --omit=dev && npm cache clean --force
|
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl tzdata
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
COPY --from=builder-dev /app/.next/standalone ./
|
||||||
|
COPY --from=builder-dev /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder-dev /app/dist ./dist
|
||||||
|
COPY --from=builder-dev /app/server/migrations ./dist/init
|
||||||
|
|
||||||
|
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
||||||
|
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
||||||
|
|
||||||
|
COPY server/db/names.json ./dist/names.json
|
||||||
|
COPY server/db/ios_models.json ./dist/ios_models.json
|
||||||
|
COPY server/db/mac_models.json ./dist/mac_models.json
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
# OCI Image Labels - Build Args for dynamic values
|
# OCI Image Labels - Build Args for dynamic values
|
||||||
ARG VERSION="dev"
|
ARG VERSION="dev"
|
||||||
ARG REVISION=""
|
ARG REVISION=""
|
||||||
@@ -38,28 +59,6 @@ ARG LICENSE="AGPL-3.0"
|
|||||||
ARG IMAGE_TITLE="Pangolin"
|
ARG IMAGE_TITLE="Pangolin"
|
||||||
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Only curl and tzdata needed at runtime - no build tools!
|
|
||||||
RUN apk add --no-cache curl tzdata
|
|
||||||
|
|
||||||
# Copy pre-built node_modules from builder (already pruned to production only)
|
|
||||||
# This includes the compiled native modules like better-sqlite3
|
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/server/migrations ./dist/init
|
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
|
|
||||||
COPY ./cli/wrapper.sh /usr/local/bin/pangctl
|
|
||||||
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
|
|
||||||
|
|
||||||
COPY server/db/names.json ./dist/names.json
|
|
||||||
COPY server/db/ios_models.json ./dist/ios_models.json
|
|
||||||
COPY server/db/mac_models.json ./dist/mac_models.json
|
|
||||||
COPY public ./public
|
|
||||||
|
|
||||||
# OCI Image Labels
|
# OCI Image Labels
|
||||||
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
# https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||||
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,9 @@
|
|||||||
FROM node:22-alpine
|
FROM node:24-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
+1
-1
@@ -281,7 +281,7 @@ esbuild
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
sourcemap: "inline",
|
sourcemap: "inline",
|
||||||
target: "node22"
|
target: "node24"
|
||||||
})
|
})
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// Check if there were any errors in the build result
|
// Check if there were any errors in the build result
|
||||||
|
|||||||
+3
-3
@@ -1154,7 +1154,7 @@
|
|||||||
"actionDeleteClient": "Client löschen",
|
"actionDeleteClient": "Client löschen",
|
||||||
"actionArchiveClient": "Client archivieren",
|
"actionArchiveClient": "Client archivieren",
|
||||||
"actionUnarchiveClient": "Client dearchivieren",
|
"actionUnarchiveClient": "Client dearchivieren",
|
||||||
"actionBlockClient": "Klient sperren",
|
"actionBlockClient": "Client sperren",
|
||||||
"actionUnblockClient": "Client entsperren",
|
"actionUnblockClient": "Client entsperren",
|
||||||
"actionUpdateClient": "Client aktualisieren",
|
"actionUpdateClient": "Client aktualisieren",
|
||||||
"actionListClients": "Clients auflisten",
|
"actionListClients": "Clients auflisten",
|
||||||
@@ -2532,10 +2532,10 @@
|
|||||||
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
|
"archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?",
|
||||||
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
|
"archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.",
|
||||||
"archiveClientConfirm": "Client archivieren",
|
"archiveClientConfirm": "Client archivieren",
|
||||||
"blockClient": "Klient sperren",
|
"blockClient": "Client sperren",
|
||||||
"blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?",
|
"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.",
|
"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",
|
"active": "Aktiv",
|
||||||
"usernameOrEmail": "Benutzername oder E-Mail",
|
"usernameOrEmail": "Benutzername oder E-Mail",
|
||||||
"selectYourOrganization": "Wählen Sie Ihre Organisation",
|
"selectYourOrganization": "Wählen Sie Ihre Organisation",
|
||||||
|
|||||||
+20
-2
@@ -201,6 +201,7 @@
|
|||||||
"protocolSelect": "Select a protocol",
|
"protocolSelect": "Select a protocol",
|
||||||
"resourcePortNumber": "Port Number",
|
"resourcePortNumber": "Port Number",
|
||||||
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
"resourcePortNumberDescription": "The external port number to proxy requests.",
|
||||||
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"resourceConfig": "Configuration Snippets",
|
"resourceConfig": "Configuration Snippets",
|
||||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
||||||
@@ -246,6 +247,17 @@
|
|||||||
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
|
"orgErrorDeleteMessage": "An error occurred while deleting the organization.",
|
||||||
"orgDeleted": "Organization deleted",
|
"orgDeleted": "Organization deleted",
|
||||||
"orgDeletedMessage": "The organization and its data has been deleted.",
|
"orgDeletedMessage": "The organization and its data has been deleted.",
|
||||||
|
"deleteAccount": "Delete Account",
|
||||||
|
"deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||||
|
"deleteAccountButton": "Delete Account",
|
||||||
|
"deleteAccountConfirmTitle": "Delete Account",
|
||||||
|
"deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.",
|
||||||
|
"deleteAccountConfirmString": "delete account",
|
||||||
|
"deleteAccountSuccess": "Account Deleted",
|
||||||
|
"deleteAccountSuccessMessage": "Your account has been deleted.",
|
||||||
|
"deleteAccountError": "Failed to delete account",
|
||||||
|
"deleteAccountPreviewAccount": "Your Account",
|
||||||
|
"deleteAccountPreviewOrgs": "Organizations you own (and all their data)",
|
||||||
"orgMissing": "Organization ID Missing",
|
"orgMissing": "Organization ID Missing",
|
||||||
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
|
"orgMissingMessage": "Unable to regenerate invitation without an organization ID.",
|
||||||
"accessUsersManage": "Manage Users",
|
"accessUsersManage": "Manage Users",
|
||||||
@@ -461,6 +473,8 @@
|
|||||||
"filterByApprovalState": "Filter By Approval State",
|
"filterByApprovalState": "Filter By Approval State",
|
||||||
"approvalListEmpty": "No approvals",
|
"approvalListEmpty": "No approvals",
|
||||||
"approvalState": "Approval State",
|
"approvalState": "Approval State",
|
||||||
|
"approvalLoadMore": "Load more",
|
||||||
|
"loadingApprovals": "Loading Approvals",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
"denied": "Denied",
|
"denied": "Denied",
|
||||||
@@ -1169,7 +1183,8 @@
|
|||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"searchProgress": "Search...",
|
"searchPlaceholder": "Search...",
|
||||||
|
"emptySearchOptions": "No options found",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"orgs": "Organizations",
|
"orgs": "Organizations",
|
||||||
"loginError": "An unexpected error occurred. Please try again.",
|
"loginError": "An unexpected error occurred. Please try again.",
|
||||||
@@ -1916,6 +1931,9 @@
|
|||||||
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
"authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?",
|
||||||
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
"authPageBrandingDeleteConfirm": "Confirm Delete Branding",
|
||||||
"brandingLogoURL": "Logo URL",
|
"brandingLogoURL": "Logo URL",
|
||||||
|
"brandingLogoURLOrPath": "Logo URL or Path",
|
||||||
|
"brandingLogoPathDescription": "Enter a URL or a local path.",
|
||||||
|
"brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.",
|
||||||
"brandingPrimaryColor": "Primary Color",
|
"brandingPrimaryColor": "Primary Color",
|
||||||
"brandingLogoWidth": "Width (px)",
|
"brandingLogoWidth": "Width (px)",
|
||||||
"brandingLogoHeight": "Height (px)",
|
"brandingLogoHeight": "Height (px)",
|
||||||
@@ -2060,7 +2078,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.",
|
"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",
|
"machineClientsBannerPangolinCLI": "Pangolin CLI",
|
||||||
"machineClientsBannerOlmCLI": "Olm CLI",
|
"machineClientsBannerOlmCLI": "Olm CLI",
|
||||||
"machineClientsBannerOlmContainer": "Olm Container",
|
"machineClientsBannerOlmContainer": "Container",
|
||||||
"clientsTableUserClients": "User",
|
"clientsTableUserClients": "User",
|
||||||
"clientsTableMachineClients": "Machine",
|
"clientsTableMachineClients": "Machine",
|
||||||
"licenseTableValidUntil": "Valid Until",
|
"licenseTableValidUntil": "Valid Until",
|
||||||
|
|||||||
Generated
+2174
-1953
File diff suppressed because it is too large
Load Diff
+45
-45
@@ -13,13 +13,10 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
|
||||||
"dev:check": "npx tsc --noEmit && npm run format:check",
|
"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",
|
"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:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
|
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
|
||||||
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
|
"db:push": "npx tsx server/db/migrate.ts",
|
||||||
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
|
"db:studio": "drizzle-kit studio --config=./drizzle.config.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",
|
|
||||||
"db:clear-migrations": "rm -rf server/migrations",
|
"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: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",
|
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
|
||||||
@@ -36,8 +33,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.0",
|
"@asteasolutions/zod-to-openapi": "8.4.0",
|
||||||
"@aws-sdk/client-s3": "3.971.0",
|
"@aws-sdk/client-s3": "3.989.0",
|
||||||
"@faker-js/faker": "10.2.0",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
@@ -62,67 +59,66 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.2",
|
"@react-email/components": "1.0.7",
|
||||||
"@react-email/render": "2.0.0",
|
"@react-email/render": "2.0.4",
|
||||||
"@react-email/tailwind": "2.0.2",
|
"@react-email/tailwind": "2.0.4",
|
||||||
"@simplewebauthn/browser": "13.2.2",
|
"@simplewebauthn/browser": "13.2.2",
|
||||||
"@simplewebauthn/server": "13.2.2",
|
"@simplewebauthn/server": "13.2.2",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.12",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.13.2",
|
"axios": "1.13.5",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"date-fns": "4.1.0",
|
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"eslint": "9.39.2",
|
|
||||||
"eslint-config-next": "16.1.0",
|
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.2.1",
|
"express-rate-limit": "8.2.1",
|
||||||
"glob": "13.0.0",
|
"glob": "13.0.3",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.9.2",
|
"ioredis": "5.9.3",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.562.0",
|
"lucide-react": "0.563.0",
|
||||||
"maxmind": "5.0.1",
|
"maxmind": "5.0.5",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.9",
|
"next": "15.5.12",
|
||||||
"next-intl": "4.7.0",
|
"next-intl": "4.8.2",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "7.0.11",
|
"nodemailer": "8.0.1",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.17.1",
|
"pg": "8.18.0",
|
||||||
"posthog-node": "5.23.0",
|
"posthog-node": "5.24.15",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "9.13.0",
|
"react-day-picker": "9.13.2",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.4",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.1",
|
"react-hook-form": "7.71.1",
|
||||||
"react-icons": "5.5.0",
|
"react-icons": "5.5.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"reodotdev": "1.0.0",
|
"reodotdev": "1.0.0",
|
||||||
"resend": "6.8.0",
|
"resend": "6.9.2",
|
||||||
"semver": "7.7.3",
|
"semver": "7.7.4",
|
||||||
"stripe": "20.2.0",
|
"sshpk": "^1.18.0",
|
||||||
|
"stripe": "20.3.1",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.4.0",
|
"tailwind-merge": "3.4.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
|
"use-debounce": "^10.1.0",
|
||||||
"uuid": "13.0.0",
|
"uuid": "13.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
@@ -131,14 +127,15 @@
|
|||||||
"ws": "8.19.0",
|
"ws": "8.19.0",
|
||||||
"yaml": "2.8.2",
|
"yaml": "2.8.2",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.5",
|
"zod": "4.3.6",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.51.2",
|
"@dotenvx/dotenvx": "1.52.0",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
|
"@react-email/preview-server": "5.2.8",
|
||||||
"@tailwindcss/postcss": "4.1.18",
|
"@tailwindcss/postcss": "4.1.18",
|
||||||
"@tanstack/react-query-devtools": "5.91.1",
|
"@tanstack/react-query-devtools": "5.91.3",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
@@ -147,30 +144,33 @@
|
|||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.18.2",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "24.10.2",
|
"@types/node": "25.2.3",
|
||||||
"@types/nodemailer": "7.0.4",
|
"@types/nodemailer": "7.0.9",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.16.0",
|
"@types/pg": "8.16.0",
|
||||||
"@types/react": "19.2.7",
|
"@types/react": "19.2.14",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
|
"@types/sshpk": "^1.17.4",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
"@types/topojson-client": "3.1.5",
|
"@types/topojson-client": "3.1.5",
|
||||||
"@types/ws": "8.18.1",
|
"@types/ws": "8.18.1",
|
||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"@types/js-yaml": "4.0.9",
|
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.8",
|
"drizzle-kit": "0.31.9",
|
||||||
"esbuild": "0.27.2",
|
"esbuild": "0.27.3",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.20.1",
|
||||||
|
"eslint": "9.39.2",
|
||||||
|
"eslint-config-next": "16.1.6",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
"prettier": "3.8.0",
|
"prettier": "3.8.1",
|
||||||
"react-email": "5.2.5",
|
"react-email": "5.2.8",
|
||||||
"tailwindcss": "4.1.18",
|
"tailwindcss": "4.1.18",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.16",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.21.0",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.53.1"
|
"typescript-eslint": "8.55.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,8 @@ export enum ActionsEnum {
|
|||||||
viewLogs = "viewLogs",
|
viewLogs = "viewLogs",
|
||||||
exportLogs = "exportLogs",
|
exportLogs = "exportLogs",
|
||||||
listApprovals = "listApprovals",
|
listApprovals = "listApprovals",
|
||||||
updateApprovals = "updateApprovals"
|
updateApprovals = "updateApprovals",
|
||||||
|
signSshKey = "signSshKey"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { roleSiteResources, userSiteResources } from "@server/db";
|
||||||
|
|
||||||
|
export async function canUserAccessSiteResource({
|
||||||
|
userId,
|
||||||
|
resourceId,
|
||||||
|
roleId
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
resourceId: number;
|
||||||
|
roleId: number;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const roleResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleSiteResources.siteResourceId, resourceId),
|
||||||
|
eq(roleSiteResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (roleResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userResourceAccess = await db
|
||||||
|
.select()
|
||||||
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, userId),
|
||||||
|
eq(userSiteResources.siteResourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
+4
-4
@@ -56,15 +56,15 @@ Ensure drizzle-kit is installed.
|
|||||||
You must have a connection string in your config file, as shown above.
|
You must have a connection string in your config file, as shown above.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:pg:generate
|
npm run db:generate
|
||||||
npm run db:pg:push
|
npm run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
### SQLite
|
### SQLite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:sqlite:generate
|
npm run db:generate
|
||||||
npm run db:sqlite:push
|
npm run db:push
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Time
|
## Build Time
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { runMigrations } from "./";
|
||||||
|
|
||||||
|
await runMigrations();
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./driver";
|
export * from "./driver";
|
||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
|
export * from "./migrate";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path";
|
|||||||
|
|
||||||
const migrationsFolder = path.join("server/migrations");
|
const migrationsFolder = path.join("server/migrations");
|
||||||
|
|
||||||
const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
console.log("Running migrations...");
|
console.log("Running migrations...");
|
||||||
try {
|
try {
|
||||||
await migrate(db as any, {
|
await migrate(db as any, {
|
||||||
@@ -17,5 +17,3 @@ const runMigrations = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import {
|
|
||||||
pgTable,
|
|
||||||
serial,
|
|
||||||
varchar,
|
|
||||||
boolean,
|
|
||||||
integer,
|
|
||||||
bigint,
|
|
||||||
real,
|
|
||||||
text,
|
|
||||||
index,
|
|
||||||
uniqueIndex
|
|
||||||
} from "drizzle-orm/pg-core";
|
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { alias } from "yargs";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
real,
|
||||||
|
serial,
|
||||||
|
text,
|
||||||
|
varchar
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const domains = pgTable("domains", {
|
export const domains = pgTable("domains", {
|
||||||
domainId: varchar("domainId").primaryKey(),
|
domainId: varchar("domainId").primaryKey(),
|
||||||
@@ -55,7 +53,9 @@ export const orgs = pgTable("orgs", {
|
|||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0)
|
.default(0),
|
||||||
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
|
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const orgDomains = pgTable("orgDomains", {
|
export const orgDomains = pgTable("orgDomains", {
|
||||||
@@ -142,7 +142,8 @@ export const resources = pgTable("resources", {
|
|||||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||||
maintenanceTitle: text("maintenanceTitle"),
|
maintenanceTitle: text("maintenanceTitle"),
|
||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
|
postAuthPath: text("postAuthPath")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
@@ -187,7 +188,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||||
hcMethod: varchar("hcMethod").default("GET"),
|
hcMethod: varchar("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,7 +220,7 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
mode: varchar("mode").notNull(), // "host" | "cidr" | "port"
|
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: varchar("protocol"), // only for port mode
|
protocol: varchar("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
@@ -327,7 +330,8 @@ export const userOrgs = pgTable("userOrgs", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => roles.roleId),
|
.references(() => roles.roleId),
|
||||||
isOwner: boolean("isOwner").notNull().default(false),
|
isOwner: boolean("isOwner").notNull().default(false),
|
||||||
autoProvisioned: boolean("autoProvisioned").default(false)
|
autoProvisioned: boolean("autoProvisioned").default(false),
|
||||||
|
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
|
||||||
@@ -983,6 +987,16 @@ export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
||||||
|
messageId: serial("messageId").primaryKey(),
|
||||||
|
wsClientId: varchar("clientId"),
|
||||||
|
messageType: varchar("messageType"),
|
||||||
|
sentAt: bigint("sentAt", { mode: "number" }).notNull(),
|
||||||
|
receivedAt: bigint("receivedAt", { mode: "number" }),
|
||||||
|
error: text("error"),
|
||||||
|
complete: boolean("complete").notNull().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1043,3 +1057,4 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
|||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
|
export type RoundTripMessageTracker = InferSelectModel<typeof roundTripMessageTracker>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./driver";
|
export * from "./driver";
|
||||||
export * from "./schema/schema";
|
export * from "./schema/schema";
|
||||||
export * from "./schema/privateSchema";
|
export * from "./schema/privateSchema";
|
||||||
|
export * from "./migrate";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "path";
|
|||||||
|
|
||||||
const migrationsFolder = path.join("server/migrations");
|
const migrationsFolder = path.join("server/migrations");
|
||||||
|
|
||||||
const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
console.log("Running migrations...");
|
console.log("Running migrations...");
|
||||||
try {
|
try {
|
||||||
migrate(db as any, {
|
migrate(db as any, {
|
||||||
@@ -16,5 +16,3 @@ const runMigrations = async () => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runMigrations();
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
sqliteTable,
|
|
||||||
text,
|
|
||||||
integer,
|
|
||||||
index,
|
|
||||||
uniqueIndex
|
|
||||||
} from "drizzle-orm/sqlite-core";
|
|
||||||
import { no } from "zod/v4/locales";
|
|
||||||
|
|
||||||
export const domains = sqliteTable("domains", {
|
export const domains = sqliteTable("domains", {
|
||||||
domainId: text("domainId").primaryKey(),
|
domainId: text("domainId").primaryKey(),
|
||||||
@@ -52,7 +45,9 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0)
|
.default(0),
|
||||||
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
|
sshCaPublicKey: text("sshCaPublicKey") // SSH CA public key (OpenSSH format)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDomains = sqliteTable("userDomains", {
|
export const userDomains = sqliteTable("userDomains", {
|
||||||
@@ -162,7 +157,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||||
maintenanceTitle: text("maintenanceTitle"),
|
maintenanceTitle: text("maintenanceTitle"),
|
||||||
maintenanceMessage: text("maintenanceMessage"),
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime")
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
|
postAuthPath: text("postAuthPath")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
@@ -213,7 +209,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
}).default(true),
|
}).default(true),
|
||||||
hcMethod: text("hcMethod").default("GET"),
|
hcMethod: text("hcMethod").default("GET"),
|
||||||
hcStatus: integer("hcStatus"), // http code
|
hcStatus: integer("hcStatus"), // http code
|
||||||
hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy"
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
hcTlsServerName: text("hcTlsServerName")
|
hcTlsServerName: text("hcTlsServerName")
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -245,7 +243,7 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
mode: text("mode").notNull(), // "host" | "cidr" | "port"
|
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||||
protocol: text("protocol"), // only for port mode
|
protocol: text("protocol"), // only for port mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
@@ -637,7 +635,8 @@ export const userOrgs = sqliteTable("userOrgs", {
|
|||||||
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
|
||||||
autoProvisioned: integer("autoProvisioned", {
|
autoProvisioned: integer("autoProvisioned", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).default(false)
|
}).default(false),
|
||||||
|
pamUsername: text("pamUsername") // cleaned username for ssh and such
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
|
||||||
@@ -1079,6 +1078,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
||||||
|
messageId: integer("messageId").primaryKey({ autoIncrement: true }),
|
||||||
|
wsClientId: text("clientId"),
|
||||||
|
messageType: text("messageType"),
|
||||||
|
sentAt: integer("sentAt").notNull(),
|
||||||
|
receivedAt: integer("receivedAt"),
|
||||||
|
error: text("error"),
|
||||||
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
export type Site = InferSelectModel<typeof sites>;
|
export type Site = InferSelectModel<typeof sites>;
|
||||||
@@ -1140,3 +1149,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
|
|||||||
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
|
||||||
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||||
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
|
||||||
|
export type RoundTripMessageTracker = InferSelectModel<
|
||||||
|
typeof roundTripMessageTracker
|
||||||
|
>;
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ export const sandboxLimitSet: LimitSet = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[FeatureId.USERS]: { value: 5, description: "Starter limit" },
|
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.SITES]: { value: 5, description: "Starter limit" },
|
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.DOMAINS]: { value: 5, description: "Starter limit" },
|
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Starter limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1LimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export enum TierFeature {
|
|||||||
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional
|
||||||
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
|
||||||
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
|
||||||
AutoProvisioning = "autoProvisioning" // handle downgrade by disabling auto provisioning
|
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
|
||||||
|
SshPam = "sshPam"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
@@ -46,5 +47,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
"tier3",
|
"tier3",
|
||||||
"enterprise"
|
"enterprise"
|
||||||
],
|
],
|
||||||
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"]
|
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
|
||||||
|
[TierFeature.SshPam]: ["enterprise"]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// 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 __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing";
|
|||||||
import { createCustomer } from "#dynamic/lib/billing";
|
import { createCustomer } from "#dynamic/lib/billing";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { generateCA } from "@server/private/lib/sshCA";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
export async function createUserAccountOrg(
|
export async function createUserAccountOrg(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -79,6 +81,11 @@ export async function createUserAccountOrg(
|
|||||||
|
|
||||||
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
|
const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group;
|
||||||
|
|
||||||
|
// Generate SSH CA keys for the org
|
||||||
|
// const ca = generateCA(`${orgId}-ca`);
|
||||||
|
// const encryptionKey = config.getRawConfig().server.secret!;
|
||||||
|
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
@@ -87,7 +94,9 @@ export async function createUserAccountOrg(
|
|||||||
// subnet
|
// subnet
|
||||||
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
|
subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs?
|
||||||
utilitySubnet: utilitySubnet,
|
utilitySubnet: utilitySubnet,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString(),
|
||||||
|
// sshCaPrivateKey: encryptedCaPrivateKey,
|
||||||
|
// sshCaPublicKey: ca.publicKeyOpenSSH
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientSiteResourcesAssociationsCache,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
db,
|
||||||
|
domains,
|
||||||
|
olms,
|
||||||
|
orgDomains,
|
||||||
|
orgs,
|
||||||
|
resources,
|
||||||
|
sites
|
||||||
|
} from "@server/db";
|
||||||
|
import { newts, newtSessions } from "@server/db";
|
||||||
|
import { eq, and, inArray, sql } from "drizzle-orm";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
|
import { deletePeer } from "@server/routers/gerbil/peers";
|
||||||
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
|
|
||||||
|
export type DeleteOrgByIdResult = {
|
||||||
|
deletedNewtIds: string[];
|
||||||
|
olmsToTerminate: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes one organization and its related data. Returns ids for termination
|
||||||
|
* messages; caller should call sendTerminationMessages with the result.
|
||||||
|
* Throws if org not found.
|
||||||
|
*/
|
||||||
|
export async function deleteOrgById(
|
||||||
|
orgId: string
|
||||||
|
): Promise<DeleteOrgByIdResult> {
|
||||||
|
const [org] = await db
|
||||||
|
.select()
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Organization with ID ${orgId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgSites = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const orgClients = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.orgId, orgId));
|
||||||
|
|
||||||
|
const deletedNewtIds: string[] = [];
|
||||||
|
const olmsToTerminate: string[] = [];
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
for (const site of orgSites) {
|
||||||
|
if (site.pubKey) {
|
||||||
|
if (site.type == "wireguard") {
|
||||||
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
|
} else if (site.type == "newt") {
|
||||||
|
const [deletedNewt] = await trx
|
||||||
|
.delete(newts)
|
||||||
|
.where(eq(newts.siteId, site.siteId))
|
||||||
|
.returning();
|
||||||
|
if (deletedNewt) {
|
||||||
|
deletedNewtIds.push(deletedNewt.newtId);
|
||||||
|
await trx
|
||||||
|
.delete(newtSessions)
|
||||||
|
.where(
|
||||||
|
eq(newtSessions.newtId, deletedNewt.newtId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.info(`Deleting site ${site.siteId}`);
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
||||||
|
}
|
||||||
|
for (const client of orgClients) {
|
||||||
|
const [olm] = await trx
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.clientId, client.clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (olm) {
|
||||||
|
olmsToTerminate.push(olm.olmId);
|
||||||
|
}
|
||||||
|
logger.info(`Deleting client ${client.clientId}`);
|
||||||
|
await trx
|
||||||
|
.delete(clients)
|
||||||
|
.where(eq(clients.clientId, client.clientId));
|
||||||
|
await trx
|
||||||
|
.delete(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await trx
|
||||||
|
.delete(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(clientSitesAssociationsCache.clientId, client.clientId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const allOrgDomains = await trx
|
||||||
|
.select()
|
||||||
|
.from(orgDomains)
|
||||||
|
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(orgDomains.orgId, orgId),
|
||||||
|
eq(domains.configManaged, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const domainIdsToDelete: string[] = [];
|
||||||
|
for (const orgDomain of allOrgDomains) {
|
||||||
|
const domainId = orgDomain.domains.domainId;
|
||||||
|
const orgCount = await trx
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(orgDomains)
|
||||||
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
|
if (orgCount[0].count === 1) {
|
||||||
|
domainIdsToDelete.push(domainId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (domainIdsToDelete.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(domains)
|
||||||
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
|
}
|
||||||
|
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
||||||
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { deletedNewtIds, olmsToTerminate };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
|
||||||
|
for (const newtId of result.deletedNewtIds) {
|
||||||
|
sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch(
|
||||||
|
(error) => {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send termination message to newt:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const olmId of result.olmsToTerminate) {
|
||||||
|
sendTerminateClient(
|
||||||
|
0,
|
||||||
|
OlmErrorCodes.TERMINATED_REKEYED,
|
||||||
|
olmId
|
||||||
|
).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send termination message to olm:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
+2
-1
@@ -16,5 +16,6 @@ export enum OpenAPITags {
|
|||||||
Client = "Client",
|
Client = "Client",
|
||||||
ApiKey = "API Key",
|
ApiKey = "API Key",
|
||||||
Domain = "Domain",
|
Domain = "Domain",
|
||||||
Blueprint = "Blueprint"
|
Blueprint = "Blueprint",
|
||||||
|
Ssh = "SSH"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH CA "Server" - Pure TypeScript Implementation
|
||||||
|
*
|
||||||
|
* This module provides basic SSH Certificate Authority functionality using
|
||||||
|
* only Node.js built-in crypto module. No external dependencies or subprocesses.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. generateCA() - Creates a new CA key pair, returns CA info including the
|
||||||
|
* TrustedUserCAKeys line to add to servers
|
||||||
|
* 2. signPublicKey() - Signs a user's public key with the CA, returns a certificate
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Wire Format Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a string in SSH wire format (4-byte length prefix + data)
|
||||||
|
*/
|
||||||
|
function encodeString(data: Buffer | string): Buffer {
|
||||||
|
const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(buf.length, 0);
|
||||||
|
return Buffer.concat([len, buf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a uint32 in SSH wire format (big-endian)
|
||||||
|
*/
|
||||||
|
function encodeUInt32(value: number): Buffer {
|
||||||
|
const buf = Buffer.alloc(4);
|
||||||
|
buf.writeUInt32BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a uint64 in SSH wire format (big-endian)
|
||||||
|
*/
|
||||||
|
function encodeUInt64(value: bigint): Buffer {
|
||||||
|
const buf = Buffer.alloc(8);
|
||||||
|
buf.writeBigUInt64BE(value, 0);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a string from SSH wire format at the given offset
|
||||||
|
* Returns the string buffer and the new offset
|
||||||
|
*/
|
||||||
|
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
|
||||||
|
const len = data.readUInt32BE(offset);
|
||||||
|
const value = data.subarray(offset + 4, offset + 4 + len);
|
||||||
|
return { value, newOffset: offset + 4 + len };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Public Key Parsing/Encoding
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an OpenSSH public key line (e.g., "ssh-ed25519 AAAA... comment")
|
||||||
|
*/
|
||||||
|
function parseOpenSSHPublicKey(pubKeyLine: string): {
|
||||||
|
keyType: string;
|
||||||
|
keyData: Buffer;
|
||||||
|
comment: string;
|
||||||
|
} {
|
||||||
|
const parts = pubKeyLine.trim().split(/\s+/);
|
||||||
|
if (parts.length < 2) {
|
||||||
|
throw new Error("Invalid public key format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyType = parts[0];
|
||||||
|
const keyData = Buffer.from(parts[1], "base64");
|
||||||
|
const comment = parts.slice(2).join(" ") || "";
|
||||||
|
|
||||||
|
// Verify the key type in the blob matches
|
||||||
|
const { value: blobKeyType } = decodeString(keyData, 0);
|
||||||
|
if (blobKeyType.toString("utf8") !== keyType) {
|
||||||
|
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { keyType, keyData, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an Ed25519 public key in OpenSSH format
|
||||||
|
*/
|
||||||
|
function encodeEd25519PublicKey(publicKey: Buffer): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
encodeString("ssh-ed25519"),
|
||||||
|
encodeString(publicKey)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a public key blob as an OpenSSH public key line
|
||||||
|
*/
|
||||||
|
function formatOpenSSHPublicKey(keyBlob: Buffer, comment: string = ""): string {
|
||||||
|
const { value: keyType } = decodeString(keyBlob, 0);
|
||||||
|
const base64 = keyBlob.toString("base64");
|
||||||
|
return `${keyType.toString("utf8")} ${base64}${comment ? " " + comment : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH Certificate Building
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface CertificateOptions {
|
||||||
|
/** Serial number for the certificate */
|
||||||
|
serial?: bigint;
|
||||||
|
/** Certificate type: 1 = user, 2 = host */
|
||||||
|
certType?: number;
|
||||||
|
/** Key ID (usually username or identifier) */
|
||||||
|
keyId: string;
|
||||||
|
/** List of valid principals (usernames the cert is valid for) */
|
||||||
|
validPrincipals: string[];
|
||||||
|
/** Valid after timestamp (seconds since epoch) */
|
||||||
|
validAfter?: bigint;
|
||||||
|
/** Valid before timestamp (seconds since epoch) */
|
||||||
|
validBefore?: bigint;
|
||||||
|
/** Critical options (usually empty for user certs) */
|
||||||
|
criticalOptions?: Map<string, string>;
|
||||||
|
/** Extensions to enable */
|
||||||
|
extensions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the extensions section of the certificate
|
||||||
|
*/
|
||||||
|
function buildExtensions(extensions: string[]): Buffer {
|
||||||
|
// Extensions are a series of name-value pairs, sorted by name
|
||||||
|
// For boolean extensions, the value is empty
|
||||||
|
const sortedExtensions = [...extensions].sort();
|
||||||
|
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const ext of sortedExtensions) {
|
||||||
|
parts.push(encodeString(ext));
|
||||||
|
parts.push(encodeString("")); // Empty value for boolean extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the critical options section
|
||||||
|
*/
|
||||||
|
function buildCriticalOptions(options: Map<string, string>): Buffer {
|
||||||
|
const sortedKeys = [...options.keys()].sort();
|
||||||
|
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
parts.push(encodeString(key));
|
||||||
|
parts.push(encodeString(encodeString(options.get(key)!)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the valid principals section
|
||||||
|
*/
|
||||||
|
function buildPrincipals(principals: string[]): Buffer {
|
||||||
|
const parts: Buffer[] = [];
|
||||||
|
for (const principal of principals) {
|
||||||
|
parts.push(encodeString(principal));
|
||||||
|
}
|
||||||
|
return encodeString(Buffer.concat(parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw Ed25519 public key from an OpenSSH public key blob
|
||||||
|
*/
|
||||||
|
function extractEd25519PublicKey(keyBlob: Buffer): Buffer {
|
||||||
|
const { newOffset } = decodeString(keyBlob, 0); // Skip key type
|
||||||
|
const { value: publicKey } = decodeString(keyBlob, newOffset);
|
||||||
|
return publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CA Interface
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CAKeyPair {
|
||||||
|
/** CA private key in PEM format (keep this secret!) */
|
||||||
|
privateKeyPem: string;
|
||||||
|
/** CA public key in PEM format */
|
||||||
|
publicKeyPem: string;
|
||||||
|
/** CA public key in OpenSSH format (for TrustedUserCAKeys) */
|
||||||
|
publicKeyOpenSSH: string;
|
||||||
|
/** Raw CA public key bytes (Ed25519) */
|
||||||
|
publicKeyRaw: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignedCertificate {
|
||||||
|
/** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */
|
||||||
|
certificate: string;
|
||||||
|
/** The certificate type string */
|
||||||
|
certType: string;
|
||||||
|
/** Serial number */
|
||||||
|
serial: bigint;
|
||||||
|
/** Key ID */
|
||||||
|
keyId: string;
|
||||||
|
/** Valid principals */
|
||||||
|
validPrincipals: string[];
|
||||||
|
/** Valid from timestamp */
|
||||||
|
validAfter: Date;
|
||||||
|
/** Valid until timestamp */
|
||||||
|
validBefore: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new SSH Certificate Authority key pair.
|
||||||
|
*
|
||||||
|
* Returns the CA keys and the line to add to /etc/ssh/sshd_config:
|
||||||
|
* TrustedUserCAKeys /etc/ssh/ca.pub
|
||||||
|
*
|
||||||
|
* Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server.
|
||||||
|
*
|
||||||
|
* @param comment - Optional comment for the CA public key
|
||||||
|
* @returns CA key pair and configuration info
|
||||||
|
*/
|
||||||
|
export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
|
||||||
|
// Generate Ed25519 key pair
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
|
||||||
|
publicKeyEncoding: { type: "spki", format: "pem" },
|
||||||
|
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get raw public key bytes
|
||||||
|
const pubKeyObj = crypto.createPublicKey(publicKey);
|
||||||
|
const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" });
|
||||||
|
// Ed25519 SPKI format: 12 byte header + 32 byte key
|
||||||
|
const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32);
|
||||||
|
|
||||||
|
// Create OpenSSH format public key
|
||||||
|
const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey);
|
||||||
|
const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKeyPem: privateKey,
|
||||||
|
publicKeyPem: publicKey,
|
||||||
|
publicKeyOpenSSH,
|
||||||
|
publicKeyRaw: ed25519PubKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get and decrypt the SSH CA keys for an organization.
|
||||||
|
*
|
||||||
|
* @param orgId - Organization ID
|
||||||
|
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
|
||||||
|
* @returns CA key pair or null if not found
|
||||||
|
*/
|
||||||
|
export async function getOrgCAKeys(
|
||||||
|
orgId: string,
|
||||||
|
decryptionKey: string
|
||||||
|
): Promise<CAKeyPair | null> {
|
||||||
|
const { db, orgs } = await import("@server/db");
|
||||||
|
const { eq } = await import("drizzle-orm");
|
||||||
|
const { decrypt } = await import("@server/lib/crypto");
|
||||||
|
|
||||||
|
const [org] = await db
|
||||||
|
.select({
|
||||||
|
sshCaPrivateKey: orgs.sshCaPrivateKey,
|
||||||
|
sshCaPublicKey: orgs.sshCaPublicKey
|
||||||
|
})
|
||||||
|
.from(orgs)
|
||||||
|
.where(eq(orgs.orgId, orgId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey);
|
||||||
|
|
||||||
|
// Extract raw public key from the OpenSSH format
|
||||||
|
const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey);
|
||||||
|
const { newOffset } = decodeString(keyData, 0); // Skip key type
|
||||||
|
const { value: publicKeyRaw } = decodeString(keyData, newOffset);
|
||||||
|
|
||||||
|
// Get PEM format of public key
|
||||||
|
const pubKeyObj = crypto.createPublicKey({
|
||||||
|
key: privateKeyPem,
|
||||||
|
format: "pem"
|
||||||
|
});
|
||||||
|
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKeyPem,
|
||||||
|
publicKeyPem,
|
||||||
|
publicKeyOpenSSH: org.sshCaPublicKey,
|
||||||
|
publicKeyRaw
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a user's SSH public key with the CA, producing a certificate.
|
||||||
|
*
|
||||||
|
* The resulting certificate should be saved alongside the user's private key
|
||||||
|
* with a -cert.pub suffix. For example:
|
||||||
|
* - Private key: ~/.ssh/id_ed25519
|
||||||
|
* - Certificate: ~/.ssh/id_ed25519-cert.pub
|
||||||
|
*
|
||||||
|
* @param caPrivateKeyPem - CA private key in PEM format
|
||||||
|
* @param userPublicKeyLine - User's public key in OpenSSH format
|
||||||
|
* @param options - Certificate options (principals, validity, etc.)
|
||||||
|
* @returns Signed certificate
|
||||||
|
*/
|
||||||
|
export function signPublicKey(
|
||||||
|
caPrivateKeyPem: string,
|
||||||
|
userPublicKeyLine: string,
|
||||||
|
options: CertificateOptions
|
||||||
|
): SignedCertificate {
|
||||||
|
// Parse the user's public key
|
||||||
|
const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine);
|
||||||
|
|
||||||
|
// Determine certificate type string
|
||||||
|
let certTypeString: string;
|
||||||
|
if (keyType === "ssh-ed25519") {
|
||||||
|
certTypeString = "ssh-ed25519-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ssh-rsa") {
|
||||||
|
certTypeString = "ssh-rsa-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp256") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp384") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com";
|
||||||
|
} else if (keyType === "ecdsa-sha2-nistp521") {
|
||||||
|
certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com";
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported key type: ${keyType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CA public key from private key
|
||||||
|
const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem);
|
||||||
|
const caPubKey = crypto.createPublicKey(caPrivKey);
|
||||||
|
const caRawPubKey = caPubKey.export({ type: "spki", format: "der" });
|
||||||
|
const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32);
|
||||||
|
const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey);
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
const serial = options.serial ?? BigInt(Date.now());
|
||||||
|
const certType = options.certType ?? 1; // 1 = user cert
|
||||||
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
|
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
|
||||||
|
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
|
||||||
|
|
||||||
|
// Default extensions for user certificates
|
||||||
|
const defaultExtensions = [
|
||||||
|
"permit-X11-forwarding",
|
||||||
|
"permit-agent-forwarding",
|
||||||
|
"permit-port-forwarding",
|
||||||
|
"permit-pty",
|
||||||
|
"permit-user-rc"
|
||||||
|
];
|
||||||
|
const extensions = options.extensions ?? defaultExtensions;
|
||||||
|
const criticalOptions = options.criticalOptions ?? new Map();
|
||||||
|
|
||||||
|
// Generate nonce (random bytes)
|
||||||
|
const nonce = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
// Extract the public key portion from the user's key blob
|
||||||
|
// For Ed25519: skip the key type string, get the public key (already encoded)
|
||||||
|
let userKeyPortion: Buffer;
|
||||||
|
if (keyType === "ssh-ed25519") {
|
||||||
|
// Skip the key type string, take the rest (which is encodeString(32-byte-key))
|
||||||
|
const { newOffset } = decodeString(keyData, 0);
|
||||||
|
userKeyPortion = keyData.subarray(newOffset);
|
||||||
|
} else {
|
||||||
|
// For other key types, extract everything after the key type
|
||||||
|
const { newOffset } = decodeString(keyData, 0);
|
||||||
|
userKeyPortion = keyData.subarray(newOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the certificate body (to be signed)
|
||||||
|
const certBody = Buffer.concat([
|
||||||
|
encodeString(certTypeString),
|
||||||
|
encodeString(nonce),
|
||||||
|
userKeyPortion,
|
||||||
|
encodeUInt64(serial),
|
||||||
|
encodeUInt32(certType),
|
||||||
|
encodeString(options.keyId),
|
||||||
|
buildPrincipals(options.validPrincipals),
|
||||||
|
encodeUInt64(validAfter),
|
||||||
|
encodeUInt64(validBefore),
|
||||||
|
buildCriticalOptions(criticalOptions),
|
||||||
|
buildExtensions(extensions),
|
||||||
|
encodeString(""), // reserved
|
||||||
|
encodeString(caPubKeyBlob) // signature key (CA public key)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Sign the certificate body
|
||||||
|
const signature = crypto.sign(null, certBody, caPrivKey);
|
||||||
|
|
||||||
|
// Build the full signature blob (algorithm + signature)
|
||||||
|
const signatureBlob = Buffer.concat([
|
||||||
|
encodeString("ssh-ed25519"),
|
||||||
|
encodeString(signature)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build complete certificate
|
||||||
|
const certificate = Buffer.concat([
|
||||||
|
certBody,
|
||||||
|
encodeString(signatureBlob)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Format as OpenSSH certificate line
|
||||||
|
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certLine,
|
||||||
|
certType: certTypeString,
|
||||||
|
serial,
|
||||||
|
keyId: options.keyId,
|
||||||
|
validPrincipals: options.validPrincipals,
|
||||||
|
validAfter: new Date(Number(validAfter) * 1000),
|
||||||
|
validBefore: new Date(Number(validBefore) * 1000)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
import type { Request, Response, NextFunction } from "express";
|
||||||
import { approvals, db, type Approval } from "@server/db";
|
import { approvals, db, type Approval } from "@server/db";
|
||||||
import { eq, sql, and } from "drizzle-orm";
|
import { eq, sql, and, inArray } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -88,7 +88,7 @@ export async function countApprovals(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(approvals.orgId, orgId),
|
eq(approvals.orgId, orgId),
|
||||||
sql`${approvals.decision} in ${state}`
|
inArray(approvals.decision, state)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
type Approval
|
type Approval
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
|
import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { getUserDeviceName } from "@server/db/names";
|
||||||
|
|
||||||
@@ -37,18 +37,26 @@ const paramsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.strictObject({
|
||||||
limit: z
|
limit: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20),
|
||||||
.pipe(z.int().nonnegative()),
|
cursorPending: z.coerce // pending cursor
|
||||||
offset: z
|
.number<string>()
|
||||||
.string()
|
.int()
|
||||||
|
.max(1) // 0 means non pending
|
||||||
|
.min(0) // 1 means pending
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(undefined),
|
||||||
.transform(Number)
|
cursorTimestamp: z.coerce
|
||||||
.pipe(z.int().nonnegative()),
|
.number<string>()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
approvalState: z
|
approvalState: z
|
||||||
.enum(["pending", "approved", "denied", "all"])
|
.enum(["pending", "approved", "denied", "all"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -61,13 +69,21 @@ const querySchema = z.strictObject({
|
|||||||
.pipe(z.number().int().positive().optional())
|
.pipe(z.number().int().positive().optional())
|
||||||
});
|
});
|
||||||
|
|
||||||
async function queryApprovals(
|
async function queryApprovals({
|
||||||
orgId: string,
|
orgId,
|
||||||
limit: number,
|
limit,
|
||||||
offset: number,
|
approvalState,
|
||||||
approvalState: z.infer<typeof querySchema>["approvalState"],
|
cursorPending,
|
||||||
clientId?: number
|
cursorTimestamp,
|
||||||
) {
|
clientId
|
||||||
|
}: {
|
||||||
|
orgId: string;
|
||||||
|
limit: number;
|
||||||
|
approvalState: z.infer<typeof querySchema>["approvalState"];
|
||||||
|
cursorPending?: number;
|
||||||
|
cursorTimestamp?: number;
|
||||||
|
clientId?: number;
|
||||||
|
}) {
|
||||||
let state: Array<Approval["decision"]> = [];
|
let state: Array<Approval["decision"]> = [];
|
||||||
switch (approvalState) {
|
switch (approvalState) {
|
||||||
case "pending":
|
case "pending":
|
||||||
@@ -83,6 +99,26 @@ async function queryApprovals(
|
|||||||
state = ["approved", "denied", "pending"];
|
state = ["approved", "denied", "pending"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
eq(approvals.orgId, orgId),
|
||||||
|
sql`${approvals.decision} in ${state}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
conditions.push(eq(approvals.clientId, clientId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`;
|
||||||
|
|
||||||
|
if (cursorPending != null && cursorTimestamp != null) {
|
||||||
|
// https://stackoverflow.com/a/79720298/10322846
|
||||||
|
// composite cursor, next data means (pending, timestamp) <= cursor
|
||||||
|
conditions.push(
|
||||||
|
lte(pendingSortKey, cursorPending),
|
||||||
|
lte(approvals.timestamp, cursorTimestamp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.select({
|
.select({
|
||||||
approvalId: approvals.approvalId,
|
approvalId: approvals.approvalId,
|
||||||
@@ -105,7 +141,8 @@ async function queryApprovals(
|
|||||||
fingerprintArch: currentFingerprint.arch,
|
fingerprintArch: currentFingerprint.arch,
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
||||||
fingerprintUsername: currentFingerprint.username,
|
fingerprintUsername: currentFingerprint.username,
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
fingerprintHostname: currentFingerprint.hostname,
|
||||||
|
timestamp: approvals.timestamp
|
||||||
})
|
})
|
||||||
.from(approvals)
|
.from(approvals)
|
||||||
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
.innerJoin(users, and(eq(approvals.userId, users.userId)))
|
||||||
@@ -118,22 +155,12 @@ async function queryApprovals(
|
|||||||
)
|
)
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
||||||
.where(
|
.where(and(...conditions))
|
||||||
and(
|
.orderBy(desc(pendingSortKey), desc(approvals.timestamp))
|
||||||
eq(approvals.orgId, orgId),
|
.limit(limit + 1); // the `+1` is used for the cursor
|
||||||
sql`${approvals.decision} in ${state}`,
|
|
||||||
...(clientId ? [eq(approvals.clientId, clientId)] : [])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(
|
|
||||||
sql`CASE ${approvals.decision} WHEN 'pending' THEN 0 ELSE 1 END`,
|
|
||||||
desc(approvals.timestamp)
|
|
||||||
)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
// Process results to format device names and build fingerprint objects
|
// Process results to format device names and build fingerprint objects
|
||||||
return res.map((approval) => {
|
const approvalsList = res.slice(0, limit).map((approval) => {
|
||||||
const model = approval.deviceModel || null;
|
const model = approval.deviceModel || null;
|
||||||
const deviceName = approval.clientName
|
const deviceName = approval.clientName
|
||||||
? getUserDeviceName(model, approval.clientName)
|
? getUserDeviceName(model, approval.clientName)
|
||||||
@@ -152,15 +179,15 @@ async function queryApprovals(
|
|||||||
|
|
||||||
const fingerprint = hasFingerprintData
|
const fingerprint = hasFingerprintData
|
||||||
? {
|
? {
|
||||||
platform: approval.fingerprintPlatform || null,
|
platform: approval.fingerprintPlatform ?? null,
|
||||||
osVersion: approval.fingerprintOsVersion || null,
|
osVersion: approval.fingerprintOsVersion ?? null,
|
||||||
kernelVersion: approval.fingerprintKernelVersion || null,
|
kernelVersion: approval.fingerprintKernelVersion ?? null,
|
||||||
arch: approval.fingerprintArch || null,
|
arch: approval.fingerprintArch ?? null,
|
||||||
deviceModel: approval.deviceModel || null,
|
deviceModel: approval.deviceModel ?? null,
|
||||||
serialNumber: approval.fingerprintSerialNumber || null,
|
serialNumber: approval.fingerprintSerialNumber ?? null,
|
||||||
username: approval.fingerprintUsername || null,
|
username: approval.fingerprintUsername ?? null,
|
||||||
hostname: approval.fingerprintHostname || null
|
hostname: approval.fingerprintHostname ?? null
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -183,11 +210,30 @@ async function queryApprovals(
|
|||||||
niceId: approval.niceId || null
|
niceId: approval.niceId || null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
let nextCursorPending: number | null = null;
|
||||||
|
let nextCursorTimestamp: number | null = null;
|
||||||
|
if (res.length > limit) {
|
||||||
|
const lastItem = res[limit];
|
||||||
|
nextCursorPending = lastItem.decision === "pending" ? 1 : 0;
|
||||||
|
nextCursorTimestamp = lastItem.timestamp;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
approvalsList,
|
||||||
|
nextCursorPending,
|
||||||
|
nextCursorTimestamp
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListApprovalsResponse = {
|
export type ListApprovalsResponse = {
|
||||||
approvals: NonNullable<Awaited<ReturnType<typeof queryApprovals>>>;
|
approvals: NonNullable<
|
||||||
pagination: { total: number; limit: number; offset: number };
|
Awaited<ReturnType<typeof queryApprovals>>
|
||||||
|
>["approvalsList"];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
cursorPending: number | null;
|
||||||
|
cursorTimestamp: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function listApprovals(
|
export async function listApprovals(
|
||||||
@@ -215,17 +261,25 @@ export async function listApprovals(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, approvalState, clientId } = parsedQuery.data;
|
const {
|
||||||
|
limit,
|
||||||
|
cursorPending,
|
||||||
|
cursorTimestamp,
|
||||||
|
approvalState,
|
||||||
|
clientId
|
||||||
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
const approvalsList = await queryApprovals(
|
const { approvalsList, nextCursorPending, nextCursorTimestamp } =
|
||||||
orgId.toString(),
|
await queryApprovals({
|
||||||
limit,
|
orgId: orgId.toString(),
|
||||||
offset,
|
limit,
|
||||||
approvalState,
|
cursorPending,
|
||||||
clientId
|
cursorTimestamp,
|
||||||
);
|
approvalState,
|
||||||
|
clientId
|
||||||
|
});
|
||||||
|
|
||||||
const [{ count }] = await db
|
const [{ count }] = await db
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
@@ -237,7 +291,8 @@ export async function listApprovals(
|
|||||||
pagination: {
|
pagination: {
|
||||||
total: count,
|
total: count,
|
||||||
limit,
|
limit,
|
||||||
offset
|
cursorPending: nextCursorPending,
|
||||||
|
cursorTimestamp: nextCursorTimestamp
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -18,6 +18,113 @@ import logger from "@server/logger";
|
|||||||
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
import { db, idp, idpOrg, loginPage, loginPageBranding, loginPageBrandingOrg, loginPageOrg, orgs, resources, roles } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
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(
|
export async function handleTierChange(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
newTier: SubscriptionType | null,
|
newTier: SubscriptionType | null,
|
||||||
@@ -40,6 +147,9 @@ export async function handleTierChange(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Org ${orgId} is reverting to free tier, disabling all paid features`
|
`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
|
// Disable all features in the tier matrix
|
||||||
for (const [featureKey] of Object.entries(tierMatrix)) {
|
for (const [featureKey] of Object.entries(tierMatrix)) {
|
||||||
const feature = featureKey as TierFeature;
|
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)
|
// Get the tier (cast as Tier since we've ruled out "license" and null)
|
||||||
const tier = newTier as Tier;
|
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
|
// Check each feature in the tier matrix
|
||||||
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) {
|
||||||
const feature = featureKey as TierFeature;
|
const feature = featureKey as TierFeature;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import * as logs from "#private/routers/auditLogs";
|
|||||||
import * as misc from "#private/routers/misc";
|
import * as misc from "#private/routers/misc";
|
||||||
import * as reKey from "#private/routers/re-key";
|
import * as reKey from "#private/routers/re-key";
|
||||||
import * as approval from "#private/routers/approvals";
|
import * as approval from "#private/routers/approvals";
|
||||||
|
import * as ssh from "#private/routers/ssh";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -506,3 +507,14 @@ authenticated.put(
|
|||||||
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
verifyUserHasAction(ActionsEnum.reGenerateSecret),
|
||||||
reKey.reGenerateExitNodeSecret
|
reKey.reGenerateExitNodeSecret
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/ssh/sign-key",
|
||||||
|
verifyValidLicense,
|
||||||
|
verifyValidSubscription(tierMatrix.sshPam),
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyLimits,
|
||||||
|
// verifyUserHasAction(ActionsEnum.signSshKey),
|
||||||
|
logActionAudit(ActionsEnum.signSshKey),
|
||||||
|
ssh.signSshKey
|
||||||
|
);
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ export async function generateNewEnterpriseLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
|
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(
|
||||||
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -63,7 +64,10 @@ export async function generateNewEnterpriseLicense(
|
|||||||
|
|
||||||
const licenseData = req.body;
|
const licenseData = req.body;
|
||||||
|
|
||||||
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
|
if (
|
||||||
|
licenseData.tier != "big_license" &&
|
||||||
|
licenseData.tier != "small_license"
|
||||||
|
) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
@@ -79,7 +83,8 @@ export async function generateNewEnterpriseLicense(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
apiResponse.status || HttpCode.BAD_REQUEST,
|
apiResponse.status || HttpCode.BAD_REQUEST,
|
||||||
apiResponse.message || "Failed to create license from Fossorial API"
|
apiResponse.message ||
|
||||||
|
"Failed to create license from Fossorial API"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,8 +117,11 @@ export async function generateNewEnterpriseLicense(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
|
const tier =
|
||||||
const tierPrice = getLicensePriceSet()[tier]
|
licenseData.tier === "big_license"
|
||||||
|
? LicenseId.BIG_LICENSE
|
||||||
|
: LicenseId.SMALL_LICENSE;
|
||||||
|
const tierPrice = getLicensePriceSet()[tier];
|
||||||
|
|
||||||
const session = await stripe!.checkout.sessions.create({
|
const session = await stripe!.checkout.sessions.create({
|
||||||
client_reference_id: keyId.toString(),
|
client_reference_id: keyId.toString(),
|
||||||
@@ -122,7 +130,7 @@ export async function generateNewEnterpriseLicense(
|
|||||||
{
|
{
|
||||||
price: tierPrice, // Use the standard tier
|
price: tierPrice, // Use the standard tier
|
||||||
quantity: 1
|
quantity: 1
|
||||||
},
|
}
|
||||||
], // Start with the standard feature set that matches the free limits
|
], // Start with the standard feature set that matches the free limits
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -37,14 +38,36 @@ const bodySchema = z.strictObject({
|
|||||||
.union([
|
.union([
|
||||||
z.literal(""),
|
z.literal(""),
|
||||||
z
|
z
|
||||||
.url("Must be a valid URL")
|
.string()
|
||||||
.superRefine(async (url, ctx) => {
|
.superRefine(async (urlOrPath, ctx) => {
|
||||||
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(urlOrPath, {
|
||||||
method: "HEAD"
|
method: "HEAD"
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
return fetch(url, { method: "GET" });
|
return fetch(urlOrPath, { method: "GET" });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
|||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||||
|
|
||||||
@@ -122,12 +123,14 @@ export async function createOrgOidcIdp(
|
|||||||
|
|
||||||
let { autoProvision } = parsedBody.data;
|
let { autoProvision } = parsedBody.data;
|
||||||
|
|
||||||
const subscribed = await isSubscribed(
|
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
|
||||||
orgId,
|
const subscribed = await isSubscribed(
|
||||||
tierMatrix.deviceApprovals
|
orgId,
|
||||||
);
|
tierMatrix.deviceApprovals
|
||||||
if (!subscribed) {
|
);
|
||||||
autoProvision = false;
|
if (!subscribed) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import config from "@server/lib/config";
|
|||||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -127,12 +128,15 @@ export async function updateOrgOidcIdp(
|
|||||||
|
|
||||||
let { autoProvision } = parsedBody.data;
|
let { autoProvision } = parsedBody.data;
|
||||||
|
|
||||||
const subscribed = await isSubscribed(
|
if (build == "saas") {
|
||||||
orgId,
|
// this is not paywalled with a ee license because this whole endpoint is restricted
|
||||||
tierMatrix.deviceApprovals
|
const subscribed = await isSubscribed(
|
||||||
);
|
orgId,
|
||||||
if (!subscribed) {
|
tierMatrix.deviceApprovals
|
||||||
autoProvision = false;
|
);
|
||||||
|
if (!subscribed) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from "./signSshKey";
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { eq, or, and } from "drizzle-orm";
|
||||||
|
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
|
||||||
|
import { signPublicKey, getOrgCAKeys } from "#private/lib/sshCA";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
orgId: z.string().nonempty()
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.strictObject({
|
||||||
|
publicKey: z.string().nonempty(),
|
||||||
|
resourceId: z.number().int().positive().optional(),
|
||||||
|
resource: z.string().nonempty().optional() // this is either the nice id or the alias
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
const fields = [data.resourceId, data.resource];
|
||||||
|
const definedFields = fields.filter((field) => field !== undefined);
|
||||||
|
return definedFields.length === 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Exactly one of resourceId, niceId, or alias must be provided"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SignSshKeyResponse = {
|
||||||
|
certificate: string;
|
||||||
|
messageId: number;
|
||||||
|
sshUsername: string;
|
||||||
|
sshHost: string;
|
||||||
|
resourceId: number;
|
||||||
|
keyId: string;
|
||||||
|
validPrincipals: string[];
|
||||||
|
validAfter: string;
|
||||||
|
validBefore: string;
|
||||||
|
expiresIn: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// registry.registerPath({
|
||||||
|
// method: "post",
|
||||||
|
// path: "/org/{orgId}/ssh/sign-key",
|
||||||
|
// description: "Sign an SSH public key for access to a resource.",
|
||||||
|
// tags: [OpenAPITags.Org, OpenAPITags.Ssh],
|
||||||
|
// request: {
|
||||||
|
// params: paramsSchema,
|
||||||
|
// body: {
|
||||||
|
// content: {
|
||||||
|
// "application/json": {
|
||||||
|
// schema: bodySchema
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// responses: {}
|
||||||
|
// });
|
||||||
|
|
||||||
|
export async function signSshKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { orgId } = parsedParams.data;
|
||||||
|
const {
|
||||||
|
publicKey,
|
||||||
|
resourceId,
|
||||||
|
resource: resourceQueryString
|
||||||
|
} = parsedBody.data;
|
||||||
|
const userId = req.user?.userId;
|
||||||
|
const roleId = req.userOrgRoleId!;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userOrg] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!userOrg) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not belong to the specified organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let usernameToUse;
|
||||||
|
if (!userOrg.pamUsername) {
|
||||||
|
if (req.user?.email) {
|
||||||
|
// Extract username from email (first part before @)
|
||||||
|
usernameToUse = req.user?.email.split("@")[0];
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Unable to extract username from email"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (req.user?.username) {
|
||||||
|
usernameToUse = req.user.username;
|
||||||
|
// We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates
|
||||||
|
usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
if (!usernameToUse) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Username is not valid for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User does not have a valid email or username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we have a existing user in this org with the same
|
||||||
|
const [existingUserWithSameName] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, usernameToUse)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingUserWithSameName) {
|
||||||
|
let foundUniqueUsername = false;
|
||||||
|
for (let attempt = 0; attempt < 20; attempt++) {
|
||||||
|
const randomNum = Math.floor(Math.random() * 101); // 0 to 100
|
||||||
|
const candidateUsername = `${usernameToUse}${randomNum}`;
|
||||||
|
|
||||||
|
const [existingUser] = await db
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.orgId, orgId),
|
||||||
|
eq(userOrgs.pamUsername, candidateUsername)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
usernameToUse = candidateUsername;
|
||||||
|
foundUniqueUsername = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundUniqueUsername) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Unable to generate a unique username for SSH certificate"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
usernameToUse = userOrg.pamUsername;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get and decrypt the org's CA keys
|
||||||
|
const caKeys = await getOrgCAKeys(
|
||||||
|
orgId,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!caKeys) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"SSH CA not configured for this organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the resource exists and belongs to the org
|
||||||
|
// Build the where clause dynamically based on which field is provided
|
||||||
|
let whereClause;
|
||||||
|
if (resourceId !== undefined) {
|
||||||
|
whereClause = eq(siteResources.siteResourceId, resourceId);
|
||||||
|
} else if (resourceQueryString !== undefined) {
|
||||||
|
whereClause = or(
|
||||||
|
eq(siteResources.niceId, resourceQueryString),
|
||||||
|
eq(siteResources.alias, resourceQueryString)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// This should never happen due to the schema validation, but TypeScript doesn't know that
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"One of resourceId, niceId, or alias must be provided"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await db
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(and(whereClause, eq(siteResources.orgId, orgId)));
|
||||||
|
|
||||||
|
if (!resources || resources.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, `Resource not found`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.length > 1) {
|
||||||
|
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Multiple resources found matching the criteria`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resources[0];
|
||||||
|
|
||||||
|
if (resource.orgId !== orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"Resource does not belong to the specified organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user has access to the resource
|
||||||
|
const hasAccess = await canUserAccessSiteResource({
|
||||||
|
userId: userId,
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
roleId: roleId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.FORBIDDEN,
|
||||||
|
"User does not have access to this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the site
|
||||||
|
const [newt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, resource.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!newt) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Site associated with resource not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the public key
|
||||||
|
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||||
|
// only valid for 5 minutes
|
||||||
|
const validFor = 300n;
|
||||||
|
|
||||||
|
const cert = signPublicKey(caKeys.privateKeyPem, publicKey, {
|
||||||
|
keyId: `${usernameToUse}@${resource.niceId}`,
|
||||||
|
validPrincipals: [usernameToUse, resource.niceId],
|
||||||
|
validAfter: now - 60n, // Start 1 min ago for clock skew
|
||||||
|
validBefore: now + validFor
|
||||||
|
});
|
||||||
|
|
||||||
|
const [message] = await db
|
||||||
|
.insert(roundTripMessageTracker)
|
||||||
|
.values({
|
||||||
|
wsClientId: newt.newtId,
|
||||||
|
messageType: `newt/pam/connection`,
|
||||||
|
sentAt: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to create message tracker entry"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendToClient(newt.newtId, {
|
||||||
|
type: `newt/pam/connection`,
|
||||||
|
data: {
|
||||||
|
messageId: message.messageId,
|
||||||
|
orgId: orgId,
|
||||||
|
agentPort: 22123,
|
||||||
|
agentHost: resource.destination,
|
||||||
|
caCert: caKeys.publicKeyOpenSSH,
|
||||||
|
username: usernameToUse,
|
||||||
|
niceId: resource.niceId,
|
||||||
|
metadata: {
|
||||||
|
sudo: true, // we are hardcoding these for now but should make configurable from the role or something
|
||||||
|
homedir: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresIn = Number(validFor); // seconds
|
||||||
|
|
||||||
|
let sshHost;
|
||||||
|
if (resource.alias && resource.alias != "") {
|
||||||
|
sshHost = resource.alias;
|
||||||
|
} else {
|
||||||
|
sshHost = resource.destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response<SignSshKeyResponse>(res, {
|
||||||
|
data: {
|
||||||
|
certificate: cert.certificate,
|
||||||
|
messageId: message.messageId,
|
||||||
|
sshUsername: usernameToUse,
|
||||||
|
sshHost: sshHost,
|
||||||
|
resourceId: resource.siteResourceId,
|
||||||
|
keyId: cert.keyId,
|
||||||
|
validPrincipals: cert.validPrincipals,
|
||||||
|
validAfter: cert.validAfter.toISOString(),
|
||||||
|
validBefore: cert.validBefore.toISOString(),
|
||||||
|
expiresIn
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "SSH key signed successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error signing SSH key:", error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred while signing the SSH key"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, orgs, userOrgs, users } from "@server/db";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { verifySession } from "@server/auth/sessions/verifySession";
|
||||||
|
import {
|
||||||
|
invalidateSession,
|
||||||
|
createBlankSessionTokenCookie
|
||||||
|
} from "@server/auth/sessions/app";
|
||||||
|
import { verifyPassword } from "@server/auth/password";
|
||||||
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
|
import {
|
||||||
|
deleteOrgById,
|
||||||
|
sendTerminationMessages
|
||||||
|
} from "@server/lib/deleteOrg";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
|
const deleteMyAccountBody = z.strictObject({
|
||||||
|
password: z.string().optional(),
|
||||||
|
code: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeleteMyAccountPreviewResponse = {
|
||||||
|
preview: true;
|
||||||
|
orgs: { orgId: string; name: string }[];
|
||||||
|
twoFactorEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteMyAccountCodeRequestedResponse = {
|
||||||
|
codeRequested: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteMyAccountSuccessResponse = {
|
||||||
|
success: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-service account deletion (saas only). Returns preview when no password;
|
||||||
|
* requires password and optional 2FA code to perform deletion. Uses shared
|
||||||
|
* deleteOrgById for each owned org (delete-my-account may delete multiple orgs).
|
||||||
|
*/
|
||||||
|
export async function deleteMyAccount(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const { user, session } = await verifySession(req);
|
||||||
|
if (!user || !session) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.serverAdmin) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Server admins cannot delete their account this way"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Account deletion with password is only supported for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = deleteMyAccountBody.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsed.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { password, code } = parsed.data;
|
||||||
|
|
||||||
|
const userId = user.userId;
|
||||||
|
|
||||||
|
const ownedOrgsRows = await db
|
||||||
|
.select({
|
||||||
|
orgId: userOrgs.orgId
|
||||||
|
})
|
||||||
|
.from(userOrgs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userId),
|
||||||
|
eq(userOrgs.isOwner, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const orgIds = ownedOrgsRows.map((r) => r.orgId);
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
const orgsWithNames =
|
||||||
|
orgIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
orgId: orgs.orgId,
|
||||||
|
name: orgs.name
|
||||||
|
})
|
||||||
|
.from(orgs)
|
||||||
|
.where(inArray(orgs.orgId, orgIds))
|
||||||
|
: [];
|
||||||
|
return response<DeleteMyAccountPreviewResponse>(res, {
|
||||||
|
data: {
|
||||||
|
preview: true,
|
||||||
|
orgs: orgsWithNames.map((o) => ({
|
||||||
|
orgId: o.orgId,
|
||||||
|
name: o.name ?? ""
|
||||||
|
})),
|
||||||
|
twoFactorEnabled: user.twoFactorEnabled ?? false
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Preview",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await verifyPassword(
|
||||||
|
password,
|
||||||
|
user.passwordHash!
|
||||||
|
);
|
||||||
|
if (!validPassword) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.UNAUTHORIZED, "Invalid password")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
if (!code) {
|
||||||
|
return response<DeleteMyAccountCodeRequestedResponse>(res, {
|
||||||
|
data: { codeRequested: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Two-factor code required",
|
||||||
|
status: HttpCode.ACCEPTED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validOTP = await verifyTotpCode(
|
||||||
|
code,
|
||||||
|
user.twoFactorSecret!,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
if (!validOTP) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"The two-factor code you entered is incorrect"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDeletedNewtIds: string[] = [];
|
||||||
|
const allOlmsToTerminate: string[] = [];
|
||||||
|
|
||||||
|
for (const row of ownedOrgsRows) {
|
||||||
|
try {
|
||||||
|
const result = await deleteOrgById(row.orgId);
|
||||||
|
allDeletedNewtIds.push(...result.deletedNewtIds);
|
||||||
|
allOlmsToTerminate.push(...result.olmsToTerminate);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to delete org ${row.orgId} during account deletion`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to delete organization"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTerminationMessages({
|
||||||
|
deletedNewtIds: allDeletedNewtIds,
|
||||||
|
olmsToTerminate: allOlmsToTerminate
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx.delete(users).where(eq(users.userId, userId));
|
||||||
|
await calculateUserClientsForOrgs(userId, trx);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invalidateSession(session.sessionId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"Failed to invalidate session after account deletion",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecure = req.protocol === "https";
|
||||||
|
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
|
||||||
|
|
||||||
|
return response<DeleteMyAccountSuccessResponse>(res, {
|
||||||
|
data: { success: true },
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Account deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"An error occurred"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from "./startDeviceWebAuth";
|
|||||||
export * from "./verifyDeviceWebAuth";
|
export * from "./verifyDeviceWebAuth";
|
||||||
export * from "./pollDeviceWebAuth";
|
export * from "./pollDeviceWebAuth";
|
||||||
export * from "./lookupUser";
|
export * from "./lookupUser";
|
||||||
|
export * from "./deleteMyAccount";
|
||||||
@@ -797,7 +797,7 @@ async function notAllowed(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
const subscribed = await isSubscribed(
|
const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature
|
||||||
orgId,
|
orgId,
|
||||||
tierMatrix.loginPageDomain
|
tierMatrix.loginPageDomain
|
||||||
);
|
);
|
||||||
@@ -854,7 +854,7 @@ async function headerAuthChallenged(
|
|||||||
) {
|
) {
|
||||||
let loginPage: LoginPage | null = null;
|
let loginPage: LoginPage | null = null;
|
||||||
if (orgId) {
|
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) {
|
if (subscribed) {
|
||||||
loginPage = await getOrgLoginPage(orgId);
|
loginPage = await getOrgLoginPage(orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from "./unarchiveClient";
|
|||||||
export * from "./blockClient";
|
export * from "./blockClient";
|
||||||
export * from "./unblockClient";
|
export * from "./unblockClient";
|
||||||
export * from "./listClients";
|
export * from "./listClients";
|
||||||
|
export * from "./listUserDevices";
|
||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
|||||||
@@ -1,34 +1,38 @@
|
|||||||
import { db, olms, users } from "@server/db";
|
|
||||||
import {
|
import {
|
||||||
clients,
|
clients,
|
||||||
|
clientSitesAssociationsCache,
|
||||||
|
currentFingerprint,
|
||||||
|
db,
|
||||||
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
clientSitesAssociationsCache,
|
users
|
||||||
currentFingerprint
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
count,
|
asc,
|
||||||
|
desc,
|
||||||
eq,
|
eq,
|
||||||
inArray,
|
inArray,
|
||||||
isNotNull,
|
|
||||||
isNull,
|
isNull,
|
||||||
|
like,
|
||||||
or,
|
or,
|
||||||
sql
|
sql,
|
||||||
|
type SQL
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { getUserDeviceName } from "@server/db/names";
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
const olmVersionCache = new NodeCache({ stdTTL: 3600 });
|
||||||
|
|
||||||
@@ -89,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listClientsSchema = z.object({
|
const listClientsSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative()),
|
.openapi({
|
||||||
filter: z.enum(["user", "machine"]).optional()
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["megabytesIn", "megabytesOut"],
|
||||||
|
description: "Field to sort by"
|
||||||
|
}),
|
||||||
|
order: z
|
||||||
|
.enum(["asc", "desc"])
|
||||||
|
.optional()
|
||||||
|
.default("asc")
|
||||||
|
.catch("asc")
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "asc",
|
||||||
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter by online status"
|
||||||
|
}),
|
||||||
|
status: z.preprocess(
|
||||||
|
(val: string | undefined) => {
|
||||||
|
if (val) {
|
||||||
|
return val.split(","); // the search query array is an array joined by commas
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
z
|
||||||
|
.array(z.enum(["active", "blocked", "archived"]))
|
||||||
|
.optional()
|
||||||
|
.default(["active"])
|
||||||
|
.catch(["active"])
|
||||||
|
.openapi({
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["active", "blocked", "archived"]
|
||||||
|
},
|
||||||
|
default: ["active"],
|
||||||
|
description:
|
||||||
|
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
|
||||||
|
})
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
function queryClients(
|
function queryClientsBase() {
|
||||||
orgId: string,
|
|
||||||
accessibleClientIds: number[],
|
|
||||||
filter?: "user" | "machine"
|
|
||||||
) {
|
|
||||||
const conditions = [
|
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
|
||||||
eq(clients.orgId, orgId)
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add filter condition based on filter type
|
|
||||||
if (filter === "user") {
|
|
||||||
conditions.push(isNotNull(clients.userId));
|
|
||||||
} else if (filter === "machine") {
|
|
||||||
conditions.push(isNull(clients.userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
clientId: clients.clientId,
|
clientId: clients.clientId,
|
||||||
@@ -142,22 +194,13 @@ function queryClients(
|
|||||||
approvalState: clients.approvalState,
|
approvalState: clients.approvalState,
|
||||||
olmArchived: olms.archived,
|
olmArchived: olms.archived,
|
||||||
archived: clients.archived,
|
archived: clients.archived,
|
||||||
blocked: clients.blocked,
|
blocked: clients.blocked
|
||||||
deviceModel: currentFingerprint.deviceModel,
|
|
||||||
fingerprintPlatform: currentFingerprint.platform,
|
|
||||||
fingerprintOsVersion: currentFingerprint.osVersion,
|
|
||||||
fingerprintKernelVersion: currentFingerprint.kernelVersion,
|
|
||||||
fingerprintArch: currentFingerprint.arch,
|
|
||||||
fingerprintSerialNumber: currentFingerprint.serialNumber,
|
|
||||||
fingerprintUsername: currentFingerprint.username,
|
|
||||||
fingerprintHostname: currentFingerprint.hostname
|
|
||||||
})
|
})
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
|
||||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||||
.leftJoin(users, eq(clients.userId, users.userId))
|
.leftJoin(users, eq(clients.userId, users.userId))
|
||||||
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
|
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId));
|
||||||
.where(and(...conditions));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSiteAssociations(clientIds: number[]) {
|
async function getSiteAssociations(clientIds: number[]) {
|
||||||
@@ -175,7 +218,7 @@ async function getSiteAssociations(clientIds: number[]) {
|
|||||||
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
.where(inArray(clientSitesAssociationsCache.clientId, clientIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
||||||
sites: Array<{
|
sites: Array<{
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
@@ -186,10 +229,9 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClients>>[0] & {
|
|||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
|
|
||||||
export type ListClientsResponse = {
|
export type ListClientsResponse = PaginatedResponse<{
|
||||||
clients: Array<ClientWithSites>;
|
clients: Array<ClientWithSites>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -218,7 +260,8 @@ export async function listClients(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset, filter } = parsedQuery.data;
|
const { page, pageSize, online, query, status, sort_by, order } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
const parsedParams = listClientsParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -267,28 +310,73 @@ export async function listClients(
|
|||||||
const accessibleClientIds = accessibleClients.map(
|
const accessibleClientIds = accessibleClients.map(
|
||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
const baseQuery = queryClients(orgId, accessibleClientIds, filter);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const countConditions = [
|
const conditions = [
|
||||||
inArray(clients.clientId, accessibleClientIds),
|
and(
|
||||||
eq(clients.orgId, orgId)
|
inArray(clients.clientId, accessibleClientIds),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
isNull(clients.userId)
|
||||||
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
if (filter === "user") {
|
if (typeof online !== "undefined") {
|
||||||
countConditions.push(isNotNull(clients.userId));
|
conditions.push(eq(clients.online, online));
|
||||||
} else if (filter === "machine") {
|
|
||||||
countConditions.push(isNull(clients.userId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const countQuery = db
|
if (status.length > 0) {
|
||||||
.select({ count: count() })
|
const filterAggregates: (SQL<unknown> | undefined)[] = [];
|
||||||
.from(clients)
|
|
||||||
.where(and(...countConditions));
|
|
||||||
|
|
||||||
const clientsList = await baseQuery.limit(limit).offset(offset);
|
if (status.includes("active")) {
|
||||||
const totalCountResult = await countQuery;
|
filterAggregates.push(
|
||||||
const totalCount = totalCountResult[0].count;
|
and(eq(clients.archived, false), eq(clients.blocked, false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.includes("archived")) {
|
||||||
|
filterAggregates.push(eq(clients.archived, true));
|
||||||
|
}
|
||||||
|
if (status.includes("blocked")) {
|
||||||
|
filterAggregates.push(eq(clients.blocked, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
conditions.push(or(...filterAggregates));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${clients.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_clients"));
|
||||||
|
|
||||||
|
const listMachinesQuery = baseQuery
|
||||||
|
.limit(page)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(clients[sort_by])
|
||||||
|
: desc(clients[sort_by])
|
||||||
|
: asc(clients.clientId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [clientsList, totalCount] = await Promise.all([
|
||||||
|
listMachinesQuery,
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// Get associated sites for all clients
|
// Get associated sites for all clients
|
||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
@@ -319,14 +407,8 @@ export async function listClients(
|
|||||||
|
|
||||||
// Merge clients with their site associations and replace name with device name
|
// Merge clients with their site associations and replace name with device name
|
||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
const model = client.deviceModel || null;
|
|
||||||
let newName = client.name;
|
|
||||||
if (filter === "user") {
|
|
||||||
newName = getUserDeviceName(model, client.name);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
name: newName,
|
|
||||||
sites: sitesByClient[client.clientId] || []
|
sites: sitesByClient[client.clientId] || []
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -371,8 +453,8 @@ export async function listClients(
|
|||||||
clients: olmsWithUpdates,
|
clients: olmsWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
page,
|
||||||
offset
|
pageSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ import createHttpError from "http-errors";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { createStore } from "#dynamic/lib/rateLimitStore";
|
import { createStore } from "#dynamic/lib/rateLimitStore";
|
||||||
import { logActionAudit } from "#dynamic/middlewares";
|
import { logActionAudit } from "#dynamic/middlewares";
|
||||||
|
import { checkRoundTripMessage } from "./ws";
|
||||||
|
|
||||||
// Root routes
|
// Root routes
|
||||||
export const unauthenticated = Router();
|
export const unauthenticated = Router();
|
||||||
@@ -145,6 +146,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
@@ -1116,6 +1124,8 @@ authenticated.get(
|
|||||||
blueprints.getBlueprint
|
blueprints.getBlueprint
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
@@ -1164,6 +1174,7 @@ authRouter.post(
|
|||||||
auth.login
|
auth.login
|
||||||
);
|
);
|
||||||
authRouter.post("/logout", auth.logout);
|
authRouter.post("/logout", auth.logout);
|
||||||
|
authRouter.post("/delete-my-account", auth.deleteMyAccount);
|
||||||
authRouter.post(
|
authRouter.post(
|
||||||
"/lookup-user",
|
"/lookup-user",
|
||||||
rateLimit({
|
rateLimit({
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export async function createIdpOrgPolicy(
|
|||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ export async function createOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env.IDENTITY_PROVIDER_MODE === "org"
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret!;
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
|
||||||
const encryptedSecret = encrypt(clientSecret, key);
|
const encryptedSecret = encrypt(clientSecret, key);
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy(
|
|||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
const { roleMapping, orgMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP and policy exist
|
// Check if IDP and policy exist
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ export async function updateOidcIdp(
|
|||||||
tags
|
tags
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (process.env.IDENTITY_PROVIDER_MODE === "org") {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
const [existingIdp] = await db
|
const [existingIdp] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -866,6 +866,13 @@ authenticated.get(
|
|||||||
client.listClients
|
client.listClients
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/org/:orgId/user-devices",
|
||||||
|
verifyApiKeyOrgAccess,
|
||||||
|
verifyApiKeyHasAction(ActionsEnum.listClients),
|
||||||
|
client.listUserDevices
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/client/:clientId",
|
"/client/:clientId",
|
||||||
verifyApiKeyClientAccess,
|
verifyApiKeyClientAccess,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { FeatureId } from "@server/lib/billing";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs";
|
||||||
import { doCidrsOverlap } from "@server/lib/ip";
|
import { doCidrsOverlap } from "@server/lib/ip";
|
||||||
|
import { generateCA } from "@server/private/lib/sshCA";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
const createOrgSchema = z.strictObject({
|
const createOrgSchema = z.strictObject({
|
||||||
orgId: z.string(),
|
orgId: z.string(),
|
||||||
@@ -143,6 +145,11 @@ export async function createOrg(
|
|||||||
.from(domains)
|
.from(domains)
|
||||||
.where(eq(domains.configManaged, true));
|
.where(eq(domains.configManaged, true));
|
||||||
|
|
||||||
|
// // Generate SSH CA keys for the org
|
||||||
|
// const ca = generateCA(`${orgId}-ca`);
|
||||||
|
// const encryptionKey = config.getRawConfig().server.secret!;
|
||||||
|
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
|
||||||
|
|
||||||
const newOrg = await trx
|
const newOrg = await trx
|
||||||
.insert(orgs)
|
.insert(orgs)
|
||||||
.values({
|
.values({
|
||||||
@@ -150,7 +157,9 @@ export async function createOrg(
|
|||||||
name,
|
name,
|
||||||
subnet,
|
subnet,
|
||||||
utilitySubnet,
|
utilitySubnet,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString(),
|
||||||
|
// sshCaPrivateKey: encryptedCaPrivateKey,
|
||||||
|
// sshCaPublicKey: ca.publicKeyOpenSSH
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientSiteResourcesAssociationsCache,
|
|
||||||
clientSitesAssociationsCache,
|
|
||||||
db,
|
|
||||||
domains,
|
|
||||||
olms,
|
|
||||||
orgDomains,
|
|
||||||
resources
|
|
||||||
} from "@server/db";
|
|
||||||
import { newts, newtSessions, orgs, sites, userActions } from "@server/db";
|
|
||||||
import { eq, and, inArray, sql } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
|
||||||
import { deletePeer } from "../gerbil/peers";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { OlmErrorCodes } from "../olm/error";
|
import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
||||||
import { sendTerminateClient } from "../client/terminate";
|
|
||||||
|
|
||||||
const deleteOrgSchema = z.strictObject({
|
const deleteOrgSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -56,170 +40,9 @@ export async function deleteOrg(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
const result = await deleteOrgById(orgId);
|
||||||
const [org] = await db
|
sendTerminationMessages(result);
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Organization with ID ${orgId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// we need to handle deleting each site
|
|
||||||
const orgSites = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.orgId, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const orgClients = await db
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.orgId, orgId));
|
|
||||||
|
|
||||||
const deletedNewtIds: string[] = [];
|
|
||||||
const olmsToTerminate: string[] = [];
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
for (const site of orgSites) {
|
|
||||||
if (site.pubKey) {
|
|
||||||
if (site.type == "wireguard") {
|
|
||||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
|
||||||
} else if (site.type == "newt") {
|
|
||||||
// get the newt on the site by querying the newt table for siteId
|
|
||||||
const [deletedNewt] = await trx
|
|
||||||
.delete(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.returning();
|
|
||||||
if (deletedNewt) {
|
|
||||||
deletedNewtIds.push(deletedNewt.newtId);
|
|
||||||
|
|
||||||
// delete all of the sessions for the newt
|
|
||||||
await trx
|
|
||||||
.delete(newtSessions)
|
|
||||||
.where(
|
|
||||||
eq(newtSessions.newtId, deletedNewt.newtId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deleting site ${site.siteId}`);
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, site.siteId));
|
|
||||||
}
|
|
||||||
for (const client of orgClients) {
|
|
||||||
const [olm] = await trx
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.clientId, client.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (olm) {
|
|
||||||
olmsToTerminate.push(olm.olmId);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deleting client ${client.clientId}`);
|
|
||||||
await trx
|
|
||||||
.delete(clients)
|
|
||||||
.where(eq(clients.clientId, client.clientId));
|
|
||||||
|
|
||||||
// also delete the associations
|
|
||||||
await trx
|
|
||||||
.delete(clientSiteResourcesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
clientSiteResourcesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(clientSitesAssociationsCache)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
clientSitesAssociationsCache.clientId,
|
|
||||||
client.clientId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allOrgDomains = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgDomains)
|
|
||||||
.innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(orgDomains.orgId, orgId),
|
|
||||||
eq(domains.configManaged, false)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// For each domain, check if it belongs to multiple organizations
|
|
||||||
const domainIdsToDelete: string[] = [];
|
|
||||||
for (const orgDomain of allOrgDomains) {
|
|
||||||
const domainId = orgDomain.domains.domainId;
|
|
||||||
|
|
||||||
// Count how many organizations this domain belongs to
|
|
||||||
const orgCount = await trx
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(orgDomains)
|
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
|
||||||
|
|
||||||
// Only delete the domain if it belongs to exactly 1 organization (the one being deleted)
|
|
||||||
if (orgCount[0].count === 1) {
|
|
||||||
domainIdsToDelete.push(domainId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete domains that belong exclusively to this organization
|
|
||||||
if (domainIdsToDelete.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(domains)
|
|
||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete resources
|
|
||||||
await trx.delete(resources).where(eq(resources.orgId, orgId));
|
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send termination messages outside of transaction to prevent blocking
|
|
||||||
for (const newtId of deletedNewtIds) {
|
|
||||||
const payload = {
|
|
||||||
type: `newt/wg/terminate`,
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
// Don't await this to prevent blocking the response
|
|
||||||
sendToClient(newtId, payload).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
"Failed to send termination message to newt:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const olmId of olmsToTerminate) {
|
|
||||||
sendTerminateClient(
|
|
||||||
0, // clientId not needed since we're passing olmId
|
|
||||||
OlmErrorCodes.TERMINATED_REKEYED,
|
|
||||||
olmId
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
"Failed to send termination message to olm:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
@@ -228,6 +51,9 @@ export async function deleteOrg(
|
|||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
|
|||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import stoi from "@server/lib/stoi";
|
import stoi from "@server/lib/stoi";
|
||||||
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
|
||||||
|
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||||
|
|
||||||
const authWithAccessTokenBodySchema = z.strictObject({
|
const authWithAccessTokenBodySchema = z.strictObject({
|
||||||
accessToken: z.string(),
|
accessToken: z.string(),
|
||||||
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
|
|||||||
requestIp: req.ip
|
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, {
|
return response<AuthWithAccessTokenResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
session: token,
|
session: token,
|
||||||
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
redirectUrl
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
|
|||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
protocol: z.enum(["tcp", "udp"]),
|
protocol: z.enum(["tcp", "udp"]),
|
||||||
domainId: z.string(),
|
domainId: z.string(),
|
||||||
stickySession: z.boolean().optional()
|
stickySession: z.boolean().optional(),
|
||||||
|
postAuthPath: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(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 subdomain = parsedBody.data.subdomain;
|
||||||
const stickySession = parsedBody.data.stickySession;
|
const stickySession = parsedBody.data.stickySession;
|
||||||
|
|
||||||
@@ -255,7 +256,8 @@ async function createHttpResource(
|
|||||||
http: true,
|
http: true,
|
||||||
protocol: "tcp",
|
protocol: "tcp",
|
||||||
ssl: true,
|
ssl: true,
|
||||||
stickySession: stickySession
|
stickySession: stickySession,
|
||||||
|
postAuthPath: postAuthPath
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
|
|||||||
whitelist: boolean;
|
whitelist: boolean;
|
||||||
skipToIdpId: number | null;
|
skipToIdpId: number | null;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
postAuthPath: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getResourceAuthInfo(
|
export async function getResourceAuthInfo(
|
||||||
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
|
|||||||
url,
|
url,
|
||||||
whitelist: resource.emailWhitelistEnabled,
|
whitelist: resource.emailWhitelistEnabled,
|
||||||
skipToIdpId: resource.skipToIdpId,
|
skipToIdpId: resource.skipToIdpId,
|
||||||
orgId: resource.orgId
|
orgId: resource.orgId,
|
||||||
|
postAuthPath: resource.postAuthPath ?? null
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -1,74 +1,99 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
} from "@server/db";
|
|
||||||
import {
|
|
||||||
resources,
|
|
||||||
userResources,
|
|
||||||
roleResources,
|
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
|
resources,
|
||||||
|
roleResources,
|
||||||
|
targetHealthCheck,
|
||||||
targets,
|
targets,
|
||||||
targetHealthCheck
|
userResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { sql, eq, or, inArray, and, count } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
like,
|
||||||
|
not,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
type SQL
|
||||||
|
} from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
|
||||||
const listResourcesParamsSchema = z.strictObject({
|
const listResourcesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listResourcesSchema = z.object({
|
const listResourcesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().nonnegative()),
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
offset: z
|
default: 20,
|
||||||
.string()
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
enabled: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter resources based on enabled status"
|
||||||
|
}),
|
||||||
|
authState: z
|
||||||
|
.enum(["protected", "not_protected", "none"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["protected", "not_protected", "none"],
|
||||||
|
description:
|
||||||
|
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
|
||||||
|
}),
|
||||||
|
healthStatus: z
|
||||||
|
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
|
||||||
|
description:
|
||||||
|
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// (resource fields + a single joined target)
|
|
||||||
type JoinedRow = {
|
|
||||||
resourceId: number;
|
|
||||||
niceId: string;
|
|
||||||
name: string;
|
|
||||||
ssl: boolean;
|
|
||||||
fullDomain: string | null;
|
|
||||||
passwordId: number | null;
|
|
||||||
sso: boolean;
|
|
||||||
pincodeId: number | null;
|
|
||||||
whitelist: boolean;
|
|
||||||
http: boolean;
|
|
||||||
protocol: string;
|
|
||||||
proxyPort: number | null;
|
|
||||||
enabled: boolean;
|
|
||||||
domainId: string | null;
|
|
||||||
headerAuthId: number | null;
|
|
||||||
|
|
||||||
targetId: number | null;
|
|
||||||
targetIp: string | null;
|
|
||||||
targetPort: number | null;
|
|
||||||
targetEnabled: boolean | null;
|
|
||||||
|
|
||||||
hcHealth: string | null;
|
|
||||||
hcEnabled: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// grouped by resource with targets[])
|
// grouped by resource with targets[])
|
||||||
export type ResourceWithTargets = {
|
export type ResourceWithTargets = {
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
@@ -91,11 +116,32 @@ export type ResourceWithTargets = {
|
|||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
healthStatus?: "healthy" | "unhealthy" | "unknown";
|
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResources(accessibleResourceIds: number[], orgId: string) {
|
// Aggregate filters
|
||||||
|
const total_targets = count(targets.targetId);
|
||||||
|
const healthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unknown_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
const unhealthy_targets = sql<number>`SUM(
|
||||||
|
CASE
|
||||||
|
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
) `;
|
||||||
|
|
||||||
|
function queryResourcesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
@@ -114,14 +160,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
headerAuthId: resourceHeaderAuth.headerAuthId,
|
headerAuthId: resourceHeaderAuth.headerAuthId,
|
||||||
headerAuthExtendedCompatibilityId:
|
headerAuthExtendedCompatibilityId:
|
||||||
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
targetId: targets.targetId,
|
|
||||||
targetIp: targets.ip,
|
|
||||||
targetPort: targets.port,
|
|
||||||
targetEnabled: targets.enabled,
|
|
||||||
|
|
||||||
hcHealth: targetHealthCheck.hcHealth,
|
|
||||||
hcEnabled: targetHealthCheck.hcEnabled
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@@ -148,18 +187,18 @@ function queryResources(accessibleResourceIds: number[], orgId: string) {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
eq(targetHealthCheck.targetId, targets.targetId)
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
)
|
)
|
||||||
.where(
|
.groupBy(
|
||||||
and(
|
resources.resourceId,
|
||||||
inArray(resources.resourceId, accessibleResourceIds),
|
resourcePassword.passwordId,
|
||||||
eq(resources.orgId, orgId)
|
resourcePincode.pincodeId,
|
||||||
)
|
resourceHeaderAuth.headerAuthId,
|
||||||
|
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListResourcesResponse = {
|
export type ListResourcesResponse = PaginatedResponse<{
|
||||||
resources: ResourceWithTargets[];
|
resources: ResourceWithTargets[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -190,7 +229,8 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, authState, enabled, query, healthStatus } =
|
||||||
|
parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -252,14 +292,133 @@ export async function listResources(
|
|||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
const countQuery: any = db
|
const conditions = [
|
||||||
.select({ count: count() })
|
and(
|
||||||
.from(resources)
|
inArray(resources.resourceId, accessibleResourceIds),
|
||||||
.where(inArray(resources.resourceId, accessibleResourceIds));
|
eq(resources.orgId, orgId)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
const baseQuery = queryResources(accessibleResourceIds, orgId);
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.fullDomain})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof enabled !== "undefined") {
|
||||||
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset);
|
if (typeof authState !== "undefined") {
|
||||||
|
switch (authState) {
|
||||||
|
case "none":
|
||||||
|
conditions.push(eq(resources.http, false));
|
||||||
|
break;
|
||||||
|
case "protected":
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
eq(resources.sso, true),
|
||||||
|
eq(resources.emailWhitelistEnabled, true),
|
||||||
|
not(isNull(resourceHeaderAuth.headerAuthId)),
|
||||||
|
not(isNull(resourcePincode.pincodeId)),
|
||||||
|
not(isNull(resourcePassword.passwordId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "not_protected":
|
||||||
|
conditions.push(
|
||||||
|
not(eq(resources.sso, true)),
|
||||||
|
not(eq(resources.emailWhitelistEnabled, true)),
|
||||||
|
isNull(resourceHeaderAuth.headerAuthId),
|
||||||
|
isNull(resourcePincode.pincodeId),
|
||||||
|
isNull(resourcePassword.passwordId)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
|
||||||
|
|
||||||
|
if (typeof healthStatus !== "undefined") {
|
||||||
|
switch (healthStatus) {
|
||||||
|
case "healthy":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "degraded":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unhealthy_targets} > 0`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "no_targets":
|
||||||
|
aggregateFilters = sql`${total_targets} = 0`;
|
||||||
|
break;
|
||||||
|
case "offline":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${healthy_targets} = 0`,
|
||||||
|
sql`${unhealthy_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "unknown":
|
||||||
|
aggregateFilters = and(
|
||||||
|
sql`${total_targets} > 0`,
|
||||||
|
sql`${unknown_targets} = ${total_targets}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = queryResourcesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.having(aggregateFilters);
|
||||||
|
|
||||||
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(baseQuery.as("filtered_resources"));
|
||||||
|
|
||||||
|
const [rows, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(resources.resourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
const allResourceTargets =
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
targetId: targets.targetId,
|
||||||
|
resourceId: targets.resourceId,
|
||||||
|
ip: targets.ip,
|
||||||
|
port: targets.port,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
healthStatus: targetHealthCheck.hcHealth,
|
||||||
|
hcEnabled: targetHealthCheck.hcEnabled
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.where(inArray(targets.resourceId, resourceIdList))
|
||||||
|
.leftJoin(
|
||||||
|
targetHealthCheck,
|
||||||
|
eq(targetHealthCheck.targetId, targets.targetId)
|
||||||
|
);
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
@@ -288,44 +447,20 @@ export async function listResources(
|
|||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
entry.targets = allResourceTargets.filter(
|
||||||
row.targetId != null &&
|
(t) => t.resourceId === entry.resourceId
|
||||||
row.targetIp &&
|
);
|
||||||
row.targetPort != null &&
|
|
||||||
row.targetEnabled != null
|
|
||||||
) {
|
|
||||||
let healthStatus: "healthy" | "unhealthy" | "unknown" =
|
|
||||||
"unknown";
|
|
||||||
|
|
||||||
if (row.hcEnabled && row.hcHealth) {
|
|
||||||
healthStatus = row.hcHealth as
|
|
||||||
| "healthy"
|
|
||||||
| "unhealthy"
|
|
||||||
| "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.targets.push({
|
|
||||||
targetId: row.targetId,
|
|
||||||
ip: row.targetIp,
|
|
||||||
port: row.targetPort,
|
|
||||||
enabled: row.targetEnabled,
|
|
||||||
healthStatus: healthStatus
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
|
||||||
|
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
|
||||||
|
|
||||||
return response<ListResourcesResponse>(res, {
|
return response<ListResourcesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
resources: resourcesList,
|
resources: resourcesList,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const updateResourceParamsSchema = z.strictObject({
|
|||||||
const updateHttpResourceBodySchema = z
|
const updateHttpResourceBodySchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
subdomain: subdomainSchema.nullable().optional(),
|
subdomain: subdomainSchema.nullable().optional(),
|
||||||
ssl: z.boolean().optional(),
|
ssl: z.boolean().optional(),
|
||||||
sso: z.boolean().optional(),
|
sso: z.boolean().optional(),
|
||||||
@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
|
|||||||
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
|
||||||
maintenanceTitle: z.string().max(255).nullable().optional(),
|
maintenanceTitle: z.string().max(255).nullable().optional(),
|
||||||
maintenanceMessage: z.string().max(2000).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, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
error: "At least one field must be provided for update"
|
error: "At least one field must be provided for update"
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
import { db, exitNodes, newts } from "@server/db";
|
import {
|
||||||
import { orgs, roleSites, sites, userSites } from "@server/db";
|
db,
|
||||||
import { remoteExitNodes } from "@server/db";
|
exitNodes,
|
||||||
import logger from "@server/logger";
|
newts,
|
||||||
import HttpCode from "@server/types/HttpCode";
|
orgs,
|
||||||
|
remoteExitNodes,
|
||||||
|
roleSites,
|
||||||
|
sites,
|
||||||
|
userSites
|
||||||
|
} from "@server/db";
|
||||||
|
import cache from "@server/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import { and, count, eq, inArray, or, sql } from "drizzle-orm";
|
import logger from "@server/logger";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import semver from "semver";
|
|
||||||
import cache from "@server/lib/cache";
|
|
||||||
|
|
||||||
async function getLatestNewtVersion(): Promise<string | null> {
|
async function getLatestNewtVersion(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
@@ -74,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listSitesSchema = z.object({
|
const listSitesSchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
sort_by: z
|
||||||
|
.enum(["megabytesIn", "megabytesOut"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["megabytesIn", "megabytesOut"],
|
||||||
|
description: "Field to sort by"
|
||||||
|
}),
|
||||||
|
order: z
|
||||||
|
.enum(["asc", "desc"])
|
||||||
|
.optional()
|
||||||
|
.default("asc")
|
||||||
|
.catch("asc")
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["asc", "desc"],
|
||||||
|
default: "asc",
|
||||||
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
online: z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.transform((v) => v === "true")
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "boolean",
|
||||||
|
description: "Filter by online status"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function querySites(orgId: string, accessibleSiteIds: number[]) {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
@@ -115,23 +165,16 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
|
|||||||
.leftJoin(
|
.leftJoin(
|
||||||
remoteExitNodes,
|
remoteExitNodes,
|
||||||
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
eq(remoteExitNodes.exitNodeId, sites.exitNodeId)
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySites>>[0] & {
|
type SiteWithUpdateAvailable = Awaited<ReturnType<typeof querySitesBase>>[0] & {
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = {
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
sites: SiteWithUpdateAvailable[];
|
sites: SiteWithUpdateAvailable[];
|
||||||
pagination: { total: number; limit: number; offset: number };
|
}>;
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -160,7 +203,6 @@ export async function listSites(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
const parsedParams = listSitesParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -203,34 +245,67 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const { pageSize, page, query, sort_by, order, online } =
|
||||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
parsedQuery.data;
|
||||||
|
|
||||||
const countQuery = db
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
.select({ count: count() })
|
|
||||||
.from(sites)
|
const conditions = [
|
||||||
.where(
|
and(
|
||||||
and(
|
inArray(sites.siteId, accessibleSiteIds),
|
||||||
inArray(sites.siteId, accessibleSiteIds),
|
eq(sites.orgId, orgId)
|
||||||
eq(sites.orgId, orgId)
|
)
|
||||||
|
];
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (typeof online !== "undefined") {
|
||||||
|
conditions.push(eq(sites.online, online));
|
||||||
|
}
|
||||||
|
|
||||||
const sitesList = await baseQuery.limit(limit).offset(offset);
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
const totalCountResult = await countQuery;
|
|
||||||
const totalCount = totalCountResult[0].count;
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySitesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteListQuery = baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(
|
||||||
|
sort_by
|
||||||
|
? order === "asc"
|
||||||
|
? asc(sites[sort_by])
|
||||||
|
: desc(sites[sort_by])
|
||||||
|
: asc(sites.siteId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [totalCount, rows] = await Promise.all([
|
||||||
|
countQuery,
|
||||||
|
siteListQuery
|
||||||
|
]);
|
||||||
|
|
||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map(
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
(site) => {
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
// Initially set to false, will be updated if version check succeeds
|
||||||
// Initially set to false, will be updated if version check succeeds
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
return siteWithUpdate;
|
||||||
return siteWithUpdate;
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
try {
|
try {
|
||||||
@@ -267,8 +342,8 @@ export async function listSites(
|
|||||||
sites: sitesWithUpdates,
|
sites: sitesWithUpdates,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
limit,
|
pageSize,
|
||||||
offset
|
page
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ export async function createSiteResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode,
|
mode: mode as "host" | "cidr",
|
||||||
// protocol: mode === "port" ? protocol : null,
|
// protocol: mode === "port" ? protocol : null,
|
||||||
// proxyPort: mode === "port" ? proxyPort : null,
|
// proxyPort: mode === "port" ? proxyPort : null,
|
||||||
// destinationPort: mode === "port" ? destinationPort : null,
|
// destinationPort: mode === "port" ? destinationPort : null,
|
||||||
|
|||||||
@@ -1,41 +1,90 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@server/db";
|
|
||||||
import { siteResources, sites, SiteResource } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||||
|
import { and, asc, eq, like, or, sql } from "drizzle-orm";
|
||||||
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||||
limit: z
|
pageSize: z.coerce
|
||||||
.string()
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default("1000")
|
.catch(20)
|
||||||
.transform(Number)
|
.default(20)
|
||||||
.pipe(z.int().positive()),
|
.openapi({
|
||||||
offset: z
|
type: "integer",
|
||||||
.string()
|
default: 20,
|
||||||
|
description: "Number of items per page"
|
||||||
|
}),
|
||||||
|
page: z.coerce
|
||||||
|
.number<string>() // for prettier formatting
|
||||||
|
.int()
|
||||||
|
.min(0)
|
||||||
.optional()
|
.optional()
|
||||||
.default("0")
|
.catch(1)
|
||||||
.transform(Number)
|
.default(1)
|
||||||
.pipe(z.int().nonnegative())
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
default: 1,
|
||||||
|
description: "Page number to retrieve"
|
||||||
|
}),
|
||||||
|
query: z.string().optional(),
|
||||||
|
mode: z
|
||||||
|
.enum(["host", "cidr"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["host", "cidr"],
|
||||||
|
description: "Filter site resources by mode"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = {
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
siteResources: (SiteResource & {
|
siteResources: (SiteResource & {
|
||||||
siteName: string;
|
siteName: string;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
siteAddress: string | null;
|
siteAddress: string | null;
|
||||||
})[];
|
})[];
|
||||||
};
|
}>;
|
||||||
|
|
||||||
|
function querySiteResourcesBase() {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
siteId: siteResources.siteId,
|
||||||
|
orgId: siteResources.orgId,
|
||||||
|
niceId: siteResources.niceId,
|
||||||
|
name: siteResources.name,
|
||||||
|
mode: siteResources.mode,
|
||||||
|
protocol: siteResources.protocol,
|
||||||
|
proxyPort: siteResources.proxyPort,
|
||||||
|
destinationPort: siteResources.destinationPort,
|
||||||
|
destination: siteResources.destination,
|
||||||
|
enabled: siteResources.enabled,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
aliasAddress: siteResources.aliasAddress,
|
||||||
|
tcpPortRangeString: siteResources.tcpPortRangeString,
|
||||||
|
udpPortRangeString: siteResources.udpPortRangeString,
|
||||||
|
disableIcmp: siteResources.disableIcmp,
|
||||||
|
siteName: sites.name,
|
||||||
|
siteNiceId: sites.niceId,
|
||||||
|
siteAddress: sites.address
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||||
|
}
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "get",
|
method: "get",
|
||||||
@@ -80,39 +129,67 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
const { limit, offset } = parsedQuery.data;
|
const { page, pageSize, query, mode } = parsedQuery.data;
|
||||||
|
|
||||||
// Get all site resources for the org with site names
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
const siteResourcesList = await db
|
if (query) {
|
||||||
.select({
|
conditions.push(
|
||||||
siteResourceId: siteResources.siteResourceId,
|
or(
|
||||||
siteId: siteResources.siteId,
|
like(
|
||||||
orgId: siteResources.orgId,
|
sql`LOWER(${siteResources.name})`,
|
||||||
niceId: siteResources.niceId,
|
"%" + query.toLowerCase() + "%"
|
||||||
name: siteResources.name,
|
),
|
||||||
mode: siteResources.mode,
|
like(
|
||||||
protocol: siteResources.protocol,
|
sql`LOWER(${siteResources.niceId})`,
|
||||||
proxyPort: siteResources.proxyPort,
|
"%" + query.toLowerCase() + "%"
|
||||||
destinationPort: siteResources.destinationPort,
|
),
|
||||||
destination: siteResources.destination,
|
like(
|
||||||
enabled: siteResources.enabled,
|
sql`LOWER(${siteResources.destination})`,
|
||||||
alias: siteResources.alias,
|
"%" + query.toLowerCase() + "%"
|
||||||
aliasAddress: siteResources.aliasAddress,
|
),
|
||||||
tcpPortRangeString: siteResources.tcpPortRangeString,
|
like(
|
||||||
udpPortRangeString: siteResources.udpPortRangeString,
|
sql`LOWER(${siteResources.alias})`,
|
||||||
disableIcmp: siteResources.disableIcmp,
|
"%" + query.toLowerCase() + "%"
|
||||||
siteName: sites.name,
|
),
|
||||||
siteNiceId: sites.niceId,
|
like(
|
||||||
siteAddress: sites.address
|
sql`LOWER(${siteResources.aliasAddress})`,
|
||||||
})
|
"%" + query.toLowerCase() + "%"
|
||||||
.from(siteResources)
|
),
|
||||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId))
|
like(
|
||||||
.where(eq(siteResources.orgId, orgId))
|
sql`LOWER(${sites.name})`,
|
||||||
.limit(limit)
|
"%" + query.toLowerCase() + "%"
|
||||||
.offset(offset);
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
if (mode) {
|
||||||
data: { siteResources: siteResourcesList },
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
|
const countQuery = db.$count(
|
||||||
|
querySiteResourcesBase().where(and(...conditions))
|
||||||
|
);
|
||||||
|
|
||||||
|
const [siteResourcesList, totalCount] = await Promise.all([
|
||||||
|
baseQuery
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(pageSize * (page - 1))
|
||||||
|
.orderBy(asc(siteResources.siteResourceId)),
|
||||||
|
countQuery
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
|
data: {
|
||||||
|
siteResources: siteResourcesList,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
pageSize,
|
||||||
|
page
|
||||||
|
}
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Site resources retrieved successfully",
|
message: "Site resources retrieved successfully",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const updateSiteResourceSchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
siteId: z.int(),
|
siteId: z.int(),
|
||||||
|
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr"]).optional(),
|
mode: z.enum(["host", "cidr"]).optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
|
|||||||
await db
|
await db
|
||||||
.update(targetHealthCheck)
|
.update(targetHealthCheck)
|
||||||
.set({
|
.set({
|
||||||
hcHealth: healthStatus.status
|
hcHealth: healthStatus.status as
|
||||||
|
| "unknown"
|
||||||
|
| "healthy"
|
||||||
|
| "unhealthy"
|
||||||
})
|
})
|
||||||
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
.where(eq(targetHealthCheck.targetId, targetIdNum))
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, roundTripMessageTracker } from "@server/db";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
const checkRoundTripMessageParamsSchema = z
|
||||||
|
.object({
|
||||||
|
messageId: z
|
||||||
|
.string()
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().int().positive())
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
// registry.registerPath({
|
||||||
|
// method: "get",
|
||||||
|
// path: "/ws/round-trip-message/{messageId}",
|
||||||
|
// description:
|
||||||
|
// "Check if a round trip message has been completed by checking the roundTripMessageTracker table",
|
||||||
|
// tags: [OpenAPITags.WebSocket],
|
||||||
|
// request: {
|
||||||
|
// params: checkRoundTripMessageParamsSchema
|
||||||
|
// },
|
||||||
|
// responses: {}
|
||||||
|
// });
|
||||||
|
|
||||||
|
export async function checkRoundTripMessage(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = checkRoundTripMessageParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messageId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Get the round trip message from the tracker
|
||||||
|
const [message] = await db
|
||||||
|
.select()
|
||||||
|
.from(roundTripMessageTracker)
|
||||||
|
.where(eq(roundTripMessageTracker.messageId, messageId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Message not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {
|
||||||
|
messageId: message.messageId,
|
||||||
|
complete: message.complete,
|
||||||
|
sentAt: message.sentAt,
|
||||||
|
receivedAt: message.receivedAt,
|
||||||
|
error: message.error,
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Round trip message status retrieved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { db, roundTripMessageTracker } from "@server/db";
|
||||||
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
interface RoundTripCompleteMessage {
|
||||||
|
messageId: number;
|
||||||
|
complete: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleRoundTripMessage: MessageHandler = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const { message, client: c } = context;
|
||||||
|
|
||||||
|
logger.info("Handling round trip message");
|
||||||
|
|
||||||
|
const data = message.data as RoundTripCompleteMessage;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { messageId, complete, error } = data;
|
||||||
|
|
||||||
|
if (!messageId) {
|
||||||
|
logger.error("Round trip message missing messageId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the roundTripMessageTracker with completion status
|
||||||
|
await db
|
||||||
|
.update(roundTripMessageTracker)
|
||||||
|
.set({
|
||||||
|
complete: complete,
|
||||||
|
receivedAt: Math.floor(Date.now() / 1000),
|
||||||
|
error: error || null
|
||||||
|
})
|
||||||
|
.where(eq(roundTripMessageTracker.messageId, messageId));
|
||||||
|
|
||||||
|
logger.info(`Round trip message ${messageId} marked as complete: ${complete}`);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
logger.warn(`Round trip message ${messageId} completed with error: ${error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error processing round trip message:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./ws";
|
export * from "./ws";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./checkRoundTripMessage";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
handleOlmDisconnecingMessage
|
handleOlmDisconnecingMessage
|
||||||
} from "../olm";
|
} from "../olm";
|
||||||
import { handleHealthcheckStatusMessage } from "../target";
|
import { handleHealthcheckStatusMessage } from "../target";
|
||||||
|
import { handleRoundTripMessage } from "./handleRoundTripMessage";
|
||||||
import { MessageHandler } from "./types";
|
import { MessageHandler } from "./types";
|
||||||
|
|
||||||
export const messageHandlers: Record<string, MessageHandler> = {
|
export const messageHandlers: Record<string, MessageHandler> = {
|
||||||
@@ -35,7 +36,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
"newt/socket/containers": handleDockerContainersMessage,
|
"newt/socket/containers": handleDockerContainersMessage,
|
||||||
"newt/ping/request": handleNewtPingRequestMessage,
|
"newt/ping/request": handleNewtPingRequestMessage,
|
||||||
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
"newt/blueprint/apply": handleApplyBlueprintMessage,
|
||||||
"newt/healthcheck/status": handleHealthcheckStatusMessage
|
"newt/healthcheck/status": handleHealthcheckStatusMessage,
|
||||||
|
"ws/round-trip/complete": handleRoundTripMessage
|
||||||
};
|
};
|
||||||
|
|
||||||
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
startOlmOfflineChecker(); // this is to handle the offline check for olms
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
migrations.ts
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ import m10 from "./scriptsPg/1.13.0";
|
|||||||
import m11 from "./scriptsPg/1.14.0";
|
import m11 from "./scriptsPg/1.14.0";
|
||||||
import m12 from "./scriptsPg/1.15.0";
|
import m12 from "./scriptsPg/1.15.0";
|
||||||
import m13 from "./scriptsPg/1.15.3";
|
import m13 from "./scriptsPg/1.15.3";
|
||||||
|
import m14 from "./scriptsPg/1.15.4";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -36,7 +37,8 @@ const migrations = [
|
|||||||
{ version: "1.13.0", run: m10 },
|
{ version: "1.13.0", run: m10 },
|
||||||
{ version: "1.14.0", run: m11 },
|
{ version: "1.14.0", run: m11 },
|
||||||
{ version: "1.15.0", run: m12 },
|
{ 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
|
// Add new migrations here as they are created
|
||||||
] as {
|
] as {
|
||||||
version: string;
|
version: string;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import m31 from "./scriptsSqlite/1.13.0";
|
|||||||
import m32 from "./scriptsSqlite/1.14.0";
|
import m32 from "./scriptsSqlite/1.14.0";
|
||||||
import m33 from "./scriptsSqlite/1.15.0";
|
import m33 from "./scriptsSqlite/1.15.0";
|
||||||
import m34 from "./scriptsSqlite/1.15.3";
|
import m34 from "./scriptsSqlite/1.15.3";
|
||||||
|
import m35 from "./scriptsSqlite/1.15.4";
|
||||||
|
|
||||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||||
@@ -70,7 +71,8 @@ const migrations = [
|
|||||||
{ version: "1.13.0", run: m31 },
|
{ version: "1.13.0", run: m31 },
|
||||||
{ version: "1.14.0", run: m32 },
|
{ version: "1.14.0", run: m32 },
|
||||||
{ version: "1.15.0", run: m33 },
|
{ 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
|
// Add new migrations here as they are created
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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";
|
import { FeatureId } from "@server/lib/billing/features";
|
||||||
|
|
||||||
// Plan tier definitions matching the mockup
|
// Plan tier definitions matching the mockup
|
||||||
type PlanId = "starter" | "home" | "team" | "business" | "enterprise";
|
type PlanId = "basic" | "home" | "team" | "business" | "enterprise";
|
||||||
|
|
||||||
type PlanOption = {
|
type PlanOption = {
|
||||||
id: PlanId;
|
id: PlanId;
|
||||||
@@ -73,8 +73,8 @@ type PlanOption = {
|
|||||||
|
|
||||||
const planOptions: PlanOption[] = [
|
const planOptions: PlanOption[] = [
|
||||||
{
|
{
|
||||||
id: "starter",
|
id: "basic",
|
||||||
name: "Starter",
|
name: "Basic",
|
||||||
price: "Free",
|
price: "Free",
|
||||||
tierType: null
|
tierType: null
|
||||||
},
|
},
|
||||||
@@ -109,10 +109,10 @@ const planOptions: PlanOption[] = [
|
|||||||
|
|
||||||
// Tier limits mapping derived from limit sets
|
// Tier limits mapping derived from limit sets
|
||||||
const tierLimits: Record<
|
const tierLimits: Record<
|
||||||
Tier | "starter",
|
Tier | "basic",
|
||||||
{ users: number; sites: number; domains: number; remoteNodes: number }
|
{ users: number; sites: number; domains: number; remoteNodes: number }
|
||||||
> = {
|
> = {
|
||||||
starter: {
|
basic: {
|
||||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||||
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
sites: freeLimitSet[FeatureId.SITES]?.value ?? 0,
|
||||||
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0,
|
||||||
@@ -183,7 +183,7 @@ export default function BillingPage() {
|
|||||||
// Confirmation dialog state
|
// Confirmation dialog state
|
||||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
const [pendingTier, setPendingTier] = useState<{
|
const [pendingTier, setPendingTier] = useState<{
|
||||||
tier: Tier | "starter";
|
tier: Tier | "basic";
|
||||||
action: "upgrade" | "downgrade";
|
action: "upgrade" | "downgrade";
|
||||||
planName: string;
|
planName: string;
|
||||||
price: string;
|
price: string;
|
||||||
@@ -402,8 +402,8 @@ export default function BillingPage() {
|
|||||||
pendingTier.action === "upgrade" ||
|
pendingTier.action === "upgrade" ||
|
||||||
pendingTier.action === "downgrade"
|
pendingTier.action === "downgrade"
|
||||||
) {
|
) {
|
||||||
// If downgrading to starter (free tier), go to Stripe portal
|
// If downgrading to basic (free tier), go to Stripe portal
|
||||||
if (pendingTier.tier === "starter") {
|
if (pendingTier.tier === "basic") {
|
||||||
handleModifySubscription();
|
handleModifySubscription();
|
||||||
} else if (hasSubscription) {
|
} else if (hasSubscription) {
|
||||||
handleChangeTier(pendingTier.tier);
|
handleChangeTier(pendingTier.tier);
|
||||||
@@ -417,7 +417,7 @@ export default function BillingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const showTierConfirmation = (
|
const showTierConfirmation = (
|
||||||
tier: Tier | "starter",
|
tier: Tier | "basic",
|
||||||
action: "upgrade" | "downgrade",
|
action: "upgrade" | "downgrade",
|
||||||
planName: string,
|
planName: string,
|
||||||
price: string
|
price: string
|
||||||
@@ -432,9 +432,9 @@ export default function BillingPage() {
|
|||||||
|
|
||||||
// Get current plan ID from tier
|
// Get current plan ID from tier
|
||||||
const getCurrentPlanId = (): PlanId => {
|
const getCurrentPlanId = (): PlanId => {
|
||||||
if (!hasSubscription || !currentTier) return "starter";
|
if (!hasSubscription || !currentTier) return "basic";
|
||||||
const plan = planOptions.find((p) => p.tierType === currentTier);
|
const plan = planOptions.find((p) => p.tierType === currentTier);
|
||||||
return plan?.id || "starter";
|
return plan?.id || "basic";
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentPlanId = getCurrentPlanId();
|
const currentPlanId = getCurrentPlanId();
|
||||||
@@ -451,8 +451,8 @@ export default function BillingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (plan.id === currentPlanId) {
|
if (plan.id === currentPlanId) {
|
||||||
// If it's the starter plan (starter with no subscription), show as current but disabled
|
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||||
if (plan.id === "starter" && !hasSubscription) {
|
if (plan.id === "basic" && !hasSubscription) {
|
||||||
return {
|
return {
|
||||||
label: "Current Plan",
|
label: "Current Plan",
|
||||||
action: () => {},
|
action: () => {},
|
||||||
@@ -484,10 +484,10 @@ export default function BillingPage() {
|
|||||||
plan.name,
|
plan.name,
|
||||||
plan.price + (" " + plan.priceDetail || "")
|
plan.price + (" " + plan.priceDetail || "")
|
||||||
);
|
);
|
||||||
} else if (plan.id === "starter") {
|
} else if (plan.id === "basic") {
|
||||||
// Show confirmation for downgrading to starter (free tier)
|
// Show confirmation for downgrading to basic (free tier)
|
||||||
showTierConfirmation(
|
showTierConfirmation(
|
||||||
"starter",
|
"basic",
|
||||||
"downgrade",
|
"downgrade",
|
||||||
plan.name,
|
plan.name,
|
||||||
plan.price
|
plan.price
|
||||||
@@ -566,7 +566,7 @@ export default function BillingPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Check if downgrading to a tier would violate current usage limits
|
// 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;
|
feature: string;
|
||||||
currentUsage: number;
|
currentUsage: number;
|
||||||
newLimit: number;
|
newLimit: number;
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||||||
import { ListClientsResponse } from "@server/routers/client";
|
import { ListClientsResponse } from "@server/routers/client";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import type { Pagination } from "@server/types/Pagination";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -19,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let machineClients: ListClientsResponse["clients"] = [];
|
let machineClients: ListClientsResponse["clients"] = [];
|
||||||
|
let pagination: Pagination = {
|
||||||
|
page: 1,
|
||||||
|
total: 0,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const machineRes = await internal.get<
|
const machineRes = await internal.get<
|
||||||
AxiosResponse<ListClientsResponse>
|
AxiosResponse<ListClientsResponse>
|
||||||
>(
|
>(
|
||||||
`/org/${params.orgId}/clients?filter=machine`,
|
`/org/${params.orgId}/clients?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
machineClients = machineRes.data.data.clients;
|
const responseData = machineRes.data.data;
|
||||||
|
machineClients = responseData.clients;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
function formatSize(mb: number): string {
|
||||||
@@ -80,6 +89,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
<MachineClientsTable
|
<MachineClientsTable
|
||||||
machineClients={machineClientRows}
|
machineClients={machineClientRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -602,7 +602,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.biometricsEnabled
|
.biometricsEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -622,7 +623,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.diskEncrypted
|
.diskEncrypted ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -642,7 +644,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.firewallEnabled
|
.firewallEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -663,7 +666,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.autoUpdatesEnabled
|
.autoUpdatesEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -683,7 +687,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.tpmAvailable
|
.tpmAvailable ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -707,7 +712,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.windowsAntivirusEnabled
|
.windowsAntivirusEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -727,7 +733,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosSipEnabled
|
.macosSipEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -751,7 +758,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosGatekeeperEnabled
|
.macosGatekeeperEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -775,7 +783,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.macosFirewallStealthMode
|
.macosFirewallStealthMode ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -796,7 +805,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.linuxAppArmorEnabled
|
.linuxAppArmorEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
@@ -817,7 +827,8 @@ export default function GeneralPage() {
|
|||||||
)
|
)
|
||||||
? formatPostureValue(
|
? formatPostureValue(
|
||||||
client.posture
|
client.posture
|
||||||
.linuxSELinuxEnabled
|
.linuxSELinuxEnabled ===
|
||||||
|
true
|
||||||
)
|
)
|
||||||
: "-"}
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { ListClientsResponse } from "@server/routers/client";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
import type { ClientRow } from "@app/components/UserDevicesTable";
|
import type { ClientRow } from "@app/components/UserDevicesTable";
|
||||||
import UserDevicesTable from "@app/components/UserDevicesTable";
|
import UserDevicesTable from "@app/components/UserDevicesTable";
|
||||||
|
import { internal } from "@app/lib/api";
|
||||||
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
|
import { type ListUserDevicesResponse } from "@server/routers/client";
|
||||||
|
import type { Pagination } from "@server/types/Pagination";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
|
||||||
type ClientsPageProps = {
|
type ClientsPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let userClients: ListClientsResponse["clients"] = [];
|
let userClients: ListUserDevicesResponse["devices"] = [];
|
||||||
|
|
||||||
|
let pagination: Pagination = {
|
||||||
|
page: 1,
|
||||||
|
total: 0,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userRes = await internal.get<AxiosResponse<ListClientsResponse>>(
|
const userRes = await internal.get<
|
||||||
`/org/${params.orgId}/clients?filter=user`,
|
AxiosResponse<ListUserDevicesResponse>
|
||||||
|
>(
|
||||||
|
`/org/${params.orgId}/user-devices?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
userClients = userRes.data.data.clients;
|
const responseData = userRes.data.data;
|
||||||
|
userClients = responseData.devices;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
function formatSize(mb: number): string {
|
function formatSize(mb: number): string {
|
||||||
@@ -39,31 +52,29 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapClientToRow = (
|
const mapClientToRow = (
|
||||||
client: ListClientsResponse["clients"][0]
|
client: ListUserDevicesResponse["devices"][number]
|
||||||
): ClientRow => {
|
): ClientRow => {
|
||||||
// Build fingerprint object if any fingerprint data exists
|
// Build fingerprint object if any fingerprint data exists
|
||||||
const hasFingerprintData =
|
const hasFingerprintData =
|
||||||
(client as any).fingerprintPlatform ||
|
client.fingerprintPlatform ||
|
||||||
(client as any).fingerprintOsVersion ||
|
client.fingerprintOsVersion ||
|
||||||
(client as any).fingerprintKernelVersion ||
|
client.fingerprintKernelVersion ||
|
||||||
(client as any).fingerprintArch ||
|
client.fingerprintArch ||
|
||||||
(client as any).fingerprintSerialNumber ||
|
client.fingerprintSerialNumber ||
|
||||||
(client as any).fingerprintUsername ||
|
client.fingerprintUsername ||
|
||||||
(client as any).fingerprintHostname ||
|
client.fingerprintHostname ||
|
||||||
(client as any).deviceModel;
|
client.deviceModel;
|
||||||
|
|
||||||
const fingerprint = hasFingerprintData
|
const fingerprint = hasFingerprintData
|
||||||
? {
|
? {
|
||||||
platform: (client as any).fingerprintPlatform || null,
|
platform: client.fingerprintPlatform,
|
||||||
osVersion: (client as any).fingerprintOsVersion || null,
|
osVersion: client.fingerprintOsVersion,
|
||||||
kernelVersion:
|
kernelVersion: client.fingerprintKernelVersion,
|
||||||
(client as any).fingerprintKernelVersion || null,
|
arch: client.fingerprintArch,
|
||||||
arch: (client as any).fingerprintArch || null,
|
deviceModel: client.deviceModel,
|
||||||
deviceModel: (client as any).deviceModel || null,
|
serialNumber: client.fingerprintSerialNumber,
|
||||||
serialNumber:
|
username: client.fingerprintUsername,
|
||||||
(client as any).fingerprintSerialNumber || null,
|
hostname: client.fingerprintHostname
|
||||||
username: (client as any).fingerprintUsername || null,
|
|
||||||
hostname: (client as any).fingerprintHostname || null
|
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -71,19 +82,19 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
name: client.name,
|
name: client.name,
|
||||||
id: client.clientId,
|
id: client.clientId,
|
||||||
subnet: client.subnet.split("/")[0],
|
subnet: client.subnet.split("/")[0],
|
||||||
mbIn: formatSize(client.megabytesIn || 0),
|
mbIn: formatSize(client.megabytesIn ?? 0),
|
||||||
mbOut: formatSize(client.megabytesOut || 0),
|
mbOut: formatSize(client.megabytesOut ?? 0),
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
online: client.online,
|
online: client.online,
|
||||||
olmVersion: client.olmVersion || undefined,
|
olmVersion: client.olmVersion || undefined,
|
||||||
olmUpdateAvailable: client.olmUpdateAvailable || false,
|
olmUpdateAvailable: Boolean(client.olmUpdateAvailable),
|
||||||
userId: client.userId,
|
userId: client.userId,
|
||||||
username: client.username,
|
username: client.username,
|
||||||
userEmail: client.userEmail,
|
userEmail: client.userEmail,
|
||||||
niceId: client.niceId,
|
niceId: client.niceId,
|
||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: Boolean(client.archived),
|
||||||
blocked: client.blocked || false,
|
blocked: Boolean(client.blocked),
|
||||||
approvalState: client.approvalState,
|
approvalState: client.approvalState,
|
||||||
fingerprint
|
fingerprint
|
||||||
};
|
};
|
||||||
@@ -101,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
<UserDevicesTable
|
<UserDevicesTable
|
||||||
userClients={userClientRows}
|
userClients={userClientRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
export interface ClientResourcesPageProps {
|
export interface ClientResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ClientResourcesPage(
|
export default async function ClientResourcesPage(
|
||||||
@@ -22,22 +22,24 @@ export default async function ClientResourcesPage(
|
|||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
|
||||||
`/org/${params.orgId}/resources`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
resources = res.data.data.resources;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<
|
const res = await internal.get<
|
||||||
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
AxiosResponse<ListAllSiteResourcesByOrgResponse>
|
||||||
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
|
>(
|
||||||
siteResources = res.data.data.siteResources;
|
`/org/${params.orgId}/site-resources?${searchParams.toString()}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
);
|
||||||
|
const responseData = res.data.data;
|
||||||
|
siteResources = responseData.siteResources;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let org = null;
|
let org = null;
|
||||||
@@ -89,9 +91,10 @@ export default async function ClientResourcesPage(
|
|||||||
<ClientResourcesTable
|
<ClientResourcesTable
|
||||||
internalResources={internalResourceRows}
|
internalResources={internalResourceRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
defaultSort={{
|
rowCount={pagination.total}
|
||||||
id: "name",
|
pagination={{
|
||||||
desc: false
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { cache } from "react";
|
|||||||
|
|
||||||
export interface ProxyResourcesPageProps {
|
export interface ProxyResourcesPageProps {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
searchParams: Promise<{ view?: string }>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProxyResourcesPage(
|
export default async function ProxyResourcesPage(
|
||||||
@@ -24,14 +24,22 @@ export default async function ProxyResourcesPage(
|
|||||||
) {
|
) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||||
`/org/${params.orgId}/resources`,
|
`/org/${params.orgId}/resources?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
resources = res.data.data.resources;
|
const responseData = res.data.data;
|
||||||
|
resources = responseData.resources;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
@@ -104,9 +112,10 @@ export default async function ProxyResourcesPage(
|
|||||||
<ProxyResourcesTable
|
<ProxyResourcesTable
|
||||||
resources={resourceRows}
|
resources={resourceRows}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
defaultSort={{
|
rowCount={pagination.total}
|
||||||
id: "name",
|
pagination={{
|
||||||
desc: false
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</OrgProvider>
|
</OrgProvider>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import { QRCodeCanvas } from "qrcode.react";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
import { NewtSiteInstallCommands } from "@app/components/newt-install-commands";
|
||||||
import { id } from "date-fns/locale";
|
|
||||||
|
|
||||||
type SiteType = "newt" | "wireguard" | "local";
|
type SiteType = "newt" | "wireguard" | "local";
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,30 @@ import { getTranslations } from "next-intl/server";
|
|||||||
|
|
||||||
type SitesPageProps = {
|
type SitesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SitesPage(props: SitesPageProps) {
|
export default async function SitesPage(props: SitesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
|
||||||
let sites: ListSitesResponse["sites"] = [];
|
let sites: ListSitesResponse["sites"] = [];
|
||||||
|
let pagination: ListSitesResponse["pagination"] = {
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
|
||||||
`/org/${params.orgId}/sites`,
|
`/org/${params.orgId}/sites?${searchParams.toString()}`,
|
||||||
await authCookieHeader()
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
sites = res.data.data.sites;
|
const responseData = res.data.data;
|
||||||
|
sites = responseData.sites;
|
||||||
|
pagination = responseData.pagination;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -60,8 +71,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* <SitesSplashCard /> */}
|
|
||||||
|
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={t("siteManageSites")}
|
title={t("siteManageSites")}
|
||||||
description={t("siteDescription")}
|
description={t("siteDescription")}
|
||||||
@@ -69,7 +78,15 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
|
|
||||||
<SitesBanner />
|
<SitesBanner />
|
||||||
|
|
||||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
<SitesTable
|
||||||
|
sites={siteRows}
|
||||||
|
orgId={params.orgId}
|
||||||
|
rowCount={pagination.total}
|
||||||
|
pagination={{
|
||||||
|
pageIndex: pagination.page - 1,
|
||||||
|
pageSize: pagination.pageSize
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import DeleteAccountConfirmDialog from "@app/components/DeleteAccountConfirmDialog";
|
||||||
|
import UserProfileCard from "@app/components/UserProfileCard";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
|
||||||
|
type DeleteAccountClientProps = {
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteAccountClient({
|
||||||
|
displayName
|
||||||
|
}: DeleteAccountClientProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
function handleUseDifferentAccount() {
|
||||||
|
api.post("/auth/logout")
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(t("logoutError"), e);
|
||||||
|
toast({
|
||||||
|
title: t("logoutError"),
|
||||||
|
description: formatAxiosError(e, t("logoutError"))
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push(
|
||||||
|
"/auth/login?internal_redirect=/auth/delete-account"
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<UserProfileCard
|
||||||
|
identifier={displayName}
|
||||||
|
description={t("signingAs")}
|
||||||
|
onUseDifferentAccount={handleUseDifferentAccount}
|
||||||
|
useDifferentAccountText={t("deviceLoginUseDifferentAccount")}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("deleteAccountDescription")}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
{t("back")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
>
|
||||||
|
{t("deleteAccountButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DeleteAccountConfirmDialog
|
||||||
|
open={isDialogOpen}
|
||||||
|
setOpen={setIsDialogOpen}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { cache } from "react";
|
||||||
|
import DeleteAccountClient from "./DeleteAccountClient";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function DeleteAccountPage() {
|
||||||
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await getTranslations();
|
||||||
|
const displayName = getUserDisplayName({ user });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-xl font-semibold">{t("deleteAccount")}</h1>
|
||||||
|
<DeleteAccountClient displayName={displayName} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import type {
|
|||||||
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
||||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
|
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
|
||||||
|
if (normalizedPostAuthPath) {
|
||||||
|
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
|
||||||
|
}
|
||||||
|
|
||||||
const hasAuth =
|
const hasAuth =
|
||||||
authInfo.password ||
|
authInfo.password ||
|
||||||
authInfo.pincode ||
|
authInfo.pincode ||
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
|
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
|
||||||
|
|
||||||
type ApplyInternalRedirectProps = {
|
type ApplyInternalRedirectProps = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -14,9 +14,9 @@ export default function ApplyInternalRedirect({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = consumeInternalRedirectPath();
|
const target = getInternalRedirectTarget(orgId);
|
||||||
if (path) {
|
if (target) {
|
||||||
router.replace(`/${orgId}${path}`);
|
router.replace(target);
|
||||||
}
|
}
|
||||||
}, [orgId, router]);
|
}, [orgId, router]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
import { formatFingerprintInfo } from "@app/lib/formatDeviceFingerprint";
|
||||||
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import {
|
import {
|
||||||
approvalFiltersSchema,
|
approvalFiltersSchema,
|
||||||
approvalQueries,
|
approvalQueries,
|
||||||
type ApprovalItem
|
type ApprovalItem
|
||||||
} from "@app/lib/queries";
|
} from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
import { ArrowRight, Ban, Check, LaptopMinimal, RefreshCw } from "lucide-react";
|
import { Ban, Check, Loader, RefreshCw } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
@@ -54,12 +54,20 @@ export function ApprovalFeed({
|
|||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
const { isPaidUser } = usePaidStatus();
|
||||||
|
|
||||||
const { data, isFetching, refetch } = useQuery({
|
const {
|
||||||
|
data,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetchingNextPage
|
||||||
|
} = useInfiniteQuery({
|
||||||
...approvalQueries.listApprovals(orgId, filters),
|
...approvalQueries.listApprovals(orgId, filters),
|
||||||
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
enabled: isPaidUser(tierMatrix.deviceApprovals)
|
||||||
});
|
});
|
||||||
|
|
||||||
const approvals = data?.approvals ?? [];
|
const approvals = data?.pages.flatMap((data) => data.approvals) ?? [];
|
||||||
|
|
||||||
// Show empty state if no approvals are enabled for any role
|
// Show empty state if no approvals are enabled for any role
|
||||||
if (!hasApprovalsEnabled) {
|
if (!hasApprovalsEnabled) {
|
||||||
@@ -115,13 +123,13 @@ export function ApprovalFeed({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
disabled={isFetching}
|
disabled={isFetching || isLoading}
|
||||||
className="lg:static gap-2"
|
className="lg:static gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-4",
|
"size-4",
|
||||||
isFetching && "animate-spin"
|
(isFetching || isLoading) && "animate-spin"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{t("refresh")}
|
{t("refresh")}
|
||||||
@@ -145,13 +153,30 @@ export function ApprovalFeed({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{approvals.length === 0 && (
|
{approvals.length === 0 && (
|
||||||
<li className="flex justify-center items-center p-4 text-muted-foreground">
|
<li className="flex justify-center items-center p-4 text-muted-foreground gap-2">
|
||||||
{t("approvalListEmpty")}
|
{isLoading
|
||||||
|
? t("loadingApprovals")
|
||||||
|
: t("approvalListEmpty")}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Loader className="size-4 flex-none animate-spin" />
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="self-center"
|
||||||
|
size="lg"
|
||||||
|
loading={isFetchingNextPage}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
>
|
||||||
|
{t("approvalLoadMore")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { startTransition, useActionState, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import z from "zod";
|
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -13,6 +9,11 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import z from "zod";
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
@@ -21,19 +22,19 @@ import {
|
|||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle
|
SettingsSectionTitle
|
||||||
} from "./Settings";
|
} from "./Settings";
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
|
|
||||||
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { ExternalLink, InfoIcon, XIcon } from "lucide-react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import type { GetLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
@@ -45,13 +46,36 @@ export type AuthPageCustomizationProps = {
|
|||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z.union([
|
logoUrl: z.union([
|
||||||
z.literal(""),
|
z.literal(""),
|
||||||
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
|
z.string().superRefine(async (urlOrPath, ctx) => {
|
||||||
|
const parseResult = z.url().safeParse(urlOrPath);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
if (build !== "enterprise") {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "Must be a valid URL"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
validateLocalPath(urlOrPath);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message:
|
||||||
|
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(urlOrPath, {
|
||||||
method: "HEAD"
|
method: "HEAD"
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
// If HEAD fails (CORS or method not allowed), try GET
|
||||||
return fetch(url, { method: "GET" });
|
return fetch(urlOrPath, { method: "GET" });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
@@ -271,12 +295,25 @@ export default function AuthPageBrandingForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-3">
|
<FormItem className="md:col-span-3">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("brandingLogoURL")}
|
{build === "enterprise"
|
||||||
|
? t(
|
||||||
|
"brandingLogoURLOrPath"
|
||||||
|
)
|
||||||
|
: t("brandingLogoURL")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{build === "enterprise"
|
||||||
|
? t(
|
||||||
|
"brandingLogoPathDescription"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"brandingLogoURLDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource
|
|||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
import { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
|
||||||
export type InternalResourceRow = {
|
export type InternalResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -51,18 +56,22 @@ export type InternalResourceRow = {
|
|||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
defaultSort?: {
|
pagination: PaginationState;
|
||||||
id: string;
|
rowCount: number;
|
||||||
desc: boolean;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ClientResourcesTable({
|
export default function ClientResourcesTable({
|
||||||
internalResources,
|
internalResources,
|
||||||
orgId,
|
orgId,
|
||||||
defaultSort
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ClientResourcesTableProps) {
|
}: ClientResourcesTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -122,19 +131,7 @@ export default function ClientResourcesTable({
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: t("name"),
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="p-3">{t("name")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "niceId",
|
id: "niceId",
|
||||||
@@ -180,9 +177,24 @@ export default function ClientResourcesTable({
|
|||||||
accessorKey: "mode",
|
accessorKey: "mode",
|
||||||
friendlyName: t("editInternalResourceDialogMode"),
|
friendlyName: t("editInternalResourceDialogMode"),
|
||||||
header: () => (
|
header: () => (
|
||||||
<span className="p-3">
|
<ColumnFilterButton
|
||||||
{t("editInternalResourceDialogMode")}
|
options={[
|
||||||
</span>
|
{
|
||||||
|
value: "host",
|
||||||
|
label: t("editInternalResourceDialogModeHost")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "cidr",
|
||||||
|
label: t("editInternalResourceDialogModeCidr")
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
|
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("editInternalResourceDialogMode")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
@@ -300,6 +312,37 @@ export default function ClientResourcesTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function handleFilterChange(
|
||||||
|
column: string,
|
||||||
|
value: string | undefined | null
|
||||||
|
) {
|
||||||
|
searchParams.delete(column);
|
||||||
|
searchParams.delete("page");
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
searchParams.set(column, value);
|
||||||
|
}
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{selectedInternalResource && (
|
{selectedInternalResource && (
|
||||||
@@ -327,19 +370,20 @@ export default function ClientResourcesTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={internalColumns}
|
columns={internalColumns}
|
||||||
data={internalResources}
|
rows={internalResources}
|
||||||
persistPageSize="internal-resources"
|
tableId="internal-resources"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
|
||||||
onAdd={() => setIsCreateDialogOpen(true)}
|
onAdd={() => setIsCreateDialogOpen(true)}
|
||||||
addButtonText={t("resourceAdd")}
|
addButtonText={t("resourceAdd")}
|
||||||
|
onSearch={handleSearchChange}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
onPaginationChange={handlePaginationChange}
|
||||||
defaultSort={defaultSort}
|
pagination={pagination}
|
||||||
enableColumnVisibility={true}
|
rowCount={rowCount}
|
||||||
persistColumnVisibility="internal-resources"
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
aliasAddress: false
|
aliasAddress: false
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "@app/components/ui/command";
|
} from "@app/components/ui/command";
|
||||||
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
|
import { Badge } from "./ui/badge";
|
||||||
|
|
||||||
interface FilterOption {
|
interface FilterOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -61,16 +62,19 @@ export function ColumnFilter({
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
<span className="truncate">
|
|
||||||
{selectedOption
|
{selectedOption && (
|
||||||
? selectedOption.label
|
<Badge className="truncate" variant="secondary">
|
||||||
: placeholder}
|
{selectedOption
|
||||||
</span>
|
? selectedOption.label
|
||||||
|
: placeholder}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
<ChevronDownIcon className="h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="p-0 w-[200px]" align="start">
|
<PopoverContent className="p-0 w-50" align="start">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder={searchPlaceholder} />
|
<CommandInput placeholder={searchPlaceholder} />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
|
|||||||
@@ -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: usersResponse = [] } = useQuery(orgQueries.users({ orgId }));
|
||||||
const { data: clientsResponse = [] } = useQuery(
|
const { data: clientsResponse = [] } = useQuery(
|
||||||
orgQueries.clients({
|
orgQueries.clients({
|
||||||
orgId,
|
orgId
|
||||||
filters: {
|
|
||||||
filter: "machine"
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,7 +300,7 @@ export default function CreateInternalResourceDialog({
|
|||||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||||
|
|
||||||
const availableSites = sites.filter(
|
const availableSites = sites.filter(
|
||||||
(site) => site.type === "newt" && site.subnet
|
(site) => site.type === "newt"
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot
|
||||||
|
} from "@app/components/ui/input-otp";
|
||||||
|
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||||
|
import type {
|
||||||
|
DeleteMyAccountPreviewResponse,
|
||||||
|
DeleteMyAccountCodeRequestedResponse,
|
||||||
|
DeleteMyAccountSuccessResponse
|
||||||
|
} from "@server/routers/auth/deleteMyAccount";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
|
type DeleteAccountConfirmDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DeleteAccountConfirmDialog({
|
||||||
|
open,
|
||||||
|
setOpen
|
||||||
|
}: DeleteAccountConfirmDialogProps) {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const router = useRouter();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const passwordSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
password: z.string().min(1, { message: t("passwordRequired") })
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const codeSchema = useMemo(
|
||||||
|
() =>
|
||||||
|
z.object({
|
||||||
|
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||||
|
}),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<0 | 1 | 2>(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
const [preview, setPreview] =
|
||||||
|
useState<DeleteMyAccountPreviewResponse | null>(null);
|
||||||
|
const [passwordValue, setPasswordValue] = useState("");
|
||||||
|
|
||||||
|
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||||
|
resolver: zodResolver(passwordSchema),
|
||||||
|
defaultValues: { password: "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const codeForm = useForm<z.infer<typeof codeSchema>>({
|
||||||
|
resolver: zodResolver(codeSchema),
|
||||||
|
defaultValues: { code: "" }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && step === 0 && !preview) {
|
||||||
|
setLoadingPreview(true);
|
||||||
|
api.post<AxiosResponse<DeleteMyAccountPreviewResponse>>(
|
||||||
|
"/auth/delete-my-account",
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.data?.data?.preview) {
|
||||||
|
setPreview(res.data.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("deleteAccountError"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
err,
|
||||||
|
t("deleteAccountError")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingPreview(false));
|
||||||
|
}
|
||||||
|
}, [open, step, preview, api, setOpen, t]);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setStep(0);
|
||||||
|
setPreview(null);
|
||||||
|
setPasswordValue("");
|
||||||
|
passwordForm.reset();
|
||||||
|
codeForm.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleContinueToPassword() {
|
||||||
|
setStep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePasswordSubmit(
|
||||||
|
values: z.infer<typeof passwordSchema>
|
||||||
|
) {
|
||||||
|
setLoading(true);
|
||||||
|
setPasswordValue(values.password);
|
||||||
|
try {
|
||||||
|
const res = await api.post<
|
||||||
|
| AxiosResponse<DeleteMyAccountCodeRequestedResponse>
|
||||||
|
| AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||||
|
>("/auth/delete-my-account", { password: values.password });
|
||||||
|
|
||||||
|
const data = res.data?.data;
|
||||||
|
|
||||||
|
if (data && "codeRequested" in data && data.codeRequested) {
|
||||||
|
setStep(2);
|
||||||
|
} else if (data && "success" in data && data.success) {
|
||||||
|
toast({
|
||||||
|
title: t("deleteAccountSuccess"),
|
||||||
|
description: t("deleteAccountSuccessMessage")
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
router.push("/auth/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("deleteAccountError"),
|
||||||
|
description: formatAxiosError(err, t("deleteAccountError"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCodeSubmit(values: z.infer<typeof codeSchema>) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<
|
||||||
|
AxiosResponse<DeleteMyAccountSuccessResponse>
|
||||||
|
>("/auth/delete-my-account", {
|
||||||
|
password: passwordValue,
|
||||||
|
code: values.code
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data?.data?.success) {
|
||||||
|
toast({
|
||||||
|
title: t("deleteAccountSuccess"),
|
||||||
|
description: t("deleteAccountSuccessMessage")
|
||||||
|
});
|
||||||
|
setOpen(false);
|
||||||
|
reset();
|
||||||
|
router.push("/auth/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("deleteAccountError"),
|
||||||
|
description: formatAxiosError(err, t("deleteAccountError"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setOpen(val);
|
||||||
|
if (!val) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("deleteAccountConfirmTitle")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{step === 0 && (
|
||||||
|
<>
|
||||||
|
{loadingPreview ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("loading")}...
|
||||||
|
</p>
|
||||||
|
) : preview ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("deleteAccountConfirmMessage")}
|
||||||
|
</p>
|
||||||
|
<div className="rounded-md bg-muted p-3 space-y-2">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{t(
|
||||||
|
"deleteAccountPreviewAccount"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{preview.orgs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-medium mt-2">
|
||||||
|
{t(
|
||||||
|
"deleteAccountPreviewOrgs"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
{preview.orgs.map(
|
||||||
|
(org) => (
|
||||||
|
<li
|
||||||
|
key={
|
||||||
|
org.orgId
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{org.name ||
|
||||||
|
org.orgId}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold text-destructive">
|
||||||
|
{t("cannotbeUndone")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Form {...passwordForm}>
|
||||||
|
<form
|
||||||
|
id="delete-account-password-form"
|
||||||
|
onSubmit={passwordForm.handleSubmit(
|
||||||
|
handlePasswordSubmit
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={passwordForm.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t("password")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("otpAuthDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Form {...codeForm}>
|
||||||
|
<form
|
||||||
|
id="delete-account-code-form"
|
||||||
|
onSubmit={codeForm.handleSubmit(
|
||||||
|
handleCodeSubmit
|
||||||
|
)}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={codeForm.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<InputOTP
|
||||||
|
maxLength={6}
|
||||||
|
{...field}
|
||||||
|
pattern={
|
||||||
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
|
}
|
||||||
|
onChange={(
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
field.onChange(
|
||||||
|
value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
0
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
1
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
2
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
3
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
4
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputOTPSlot
|
||||||
|
index={
|
||||||
|
5
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
{step === 0 && preview && !loadingPreview && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleContinueToPassword}
|
||||||
|
>
|
||||||
|
{t("continue")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 1 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
type="submit"
|
||||||
|
form="delete-account-password-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("deleteAccountButton")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
type="submit"
|
||||||
|
form="delete-account-code-form"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("deleteAccountButton")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -277,10 +277,7 @@ export default function EditInternalResourceDialog({
|
|||||||
orgQueries.roles({ orgId }),
|
orgQueries.roles({ orgId }),
|
||||||
orgQueries.users({ orgId }),
|
orgQueries.users({ orgId }),
|
||||||
orgQueries.clients({
|
orgQueries.clients({
|
||||||
orgId,
|
orgId
|
||||||
filters: {
|
|
||||||
filter: "machine"
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
|
resourceQueries.siteResourceUsers({ siteResourceId: resource.id }),
|
||||||
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),
|
resourceQueries.siteResourceRoles({ siteResourceId: resource.id }),
|
||||||
@@ -397,7 +394,7 @@ export default function EditInternalResourceDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const availableSites = sites.filter(
|
const availableSites = sites.filter(
|
||||||
(site) => site.type === "newt" && site.subnet
|
(site) => site.type === "newt"
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<FormData>({
|
const form = useForm<FormData>({
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const MachineClientsBanner = ({ orgId }: MachineClientsBannerProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,13 +16,23 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
CircleSlash
|
CircleSlash,
|
||||||
|
ArrowDown01Icon,
|
||||||
|
ArrowUp10Icon,
|
||||||
|
ChevronsUpDownIcon
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState, useTransition } from "react";
|
import { useMemo, useState, useTransition } from "react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
|
|
||||||
export type ClientRow = {
|
export type ClientRow = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -48,14 +58,24 @@ export type ClientRow = {
|
|||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
machineClients: ClientRow[];
|
machineClients: ClientRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
pagination: PaginationState;
|
||||||
|
rowCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MachineClientsTable({
|
export default function MachineClientsTable({
|
||||||
machineClients,
|
machineClients,
|
||||||
orgId
|
orgId,
|
||||||
|
pagination,
|
||||||
|
rowCount
|
||||||
}: ClientTableProps) {
|
}: ClientTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
navigate: filter,
|
||||||
|
isNavigating: isFiltering,
|
||||||
|
searchParams
|
||||||
|
} = useNavigationContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
@@ -65,6 +85,7 @@ export default function MachineClientsTable({
|
|||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const defaultMachineColumnVisibility = {
|
const defaultMachineColumnVisibility = {
|
||||||
subnet: false,
|
subnet: false,
|
||||||
@@ -182,22 +203,8 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
friendlyName: "Name",
|
friendlyName: t("name"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("name")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Name
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const r = row.original;
|
const r = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -224,38 +231,35 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "niceId",
|
accessorKey: "niceId",
|
||||||
friendlyName: "Identifier",
|
friendlyName: "Identifier",
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("identifier")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "online",
|
accessorKey: "online",
|
||||||
friendlyName: "Connectivity",
|
friendlyName: t("online"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ColumnFilterButton
|
||||||
variant="ghost"
|
options={[
|
||||||
onClick={() =>
|
{
|
||||||
column.toggleSorting(
|
value: "true",
|
||||||
column.getIsSorted() === "asc"
|
label: t("connected")
|
||||||
)
|
},
|
||||||
|
{
|
||||||
|
value: "false",
|
||||||
|
label: t("disconnected")
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedValue={
|
||||||
|
searchParams.get("online") ?? undefined
|
||||||
}
|
}
|
||||||
>
|
onValueChange={(value) =>
|
||||||
Connectivity
|
handleFilterChange("online", value)
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
}
|
||||||
</Button>
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("online")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -279,38 +283,52 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbIn",
|
accessorKey: "mbIn",
|
||||||
friendlyName: "Data In",
|
friendlyName: t("dataIn"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataInOrder = getSortDirection(
|
||||||
|
"megabytesIn",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataInOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataInOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("megabytesIn")}
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Data In
|
{t("dataIn")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "mbOut",
|
accessorKey: "mbOut",
|
||||||
friendlyName: "Data Out",
|
friendlyName: t("dataOut"),
|
||||||
header: ({ column }) => {
|
header: () => {
|
||||||
|
const dataOutOrder = getSortDirection(
|
||||||
|
"megabytesOut",
|
||||||
|
searchParams
|
||||||
|
);
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
dataOutOrder === "asc"
|
||||||
|
? ArrowDown01Icon
|
||||||
|
: dataOutOrder === "desc"
|
||||||
|
? ArrowUp10Icon
|
||||||
|
: ChevronsUpDownIcon;
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() =>
|
onClick={() => toggleSort("megabytesOut")}
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Data Out
|
{t("dataOut")}
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -318,21 +336,7 @@ export default function MachineClientsTable({
|
|||||||
{
|
{
|
||||||
accessorKey: "client",
|
accessorKey: "client",
|
||||||
friendlyName: t("agent"),
|
friendlyName: t("agent"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("agent")}</span>,
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("agent")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
|
|
||||||
@@ -356,22 +360,8 @@ export default function MachineClientsTable({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "subnet",
|
accessorKey: "subnet",
|
||||||
friendlyName: "Address",
|
friendlyName: t("address"),
|
||||||
header: ({ column }) => {
|
header: () => <span className="px-3">{t("address")}</span>
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Address
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -455,7 +445,56 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, t]);
|
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||||
|
|
||||||
|
const booleanSearchFilterSchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
|
function handleFilterChange(
|
||||||
|
column: string,
|
||||||
|
value: string | null | undefined | string[]
|
||||||
|
) {
|
||||||
|
searchParams.delete(column);
|
||||||
|
searchParams.delete("page");
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
searchParams.set(column, value);
|
||||||
|
} else if (value) {
|
||||||
|
for (const val of value) {
|
||||||
|
searchParams.append(column, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSort(column: string) {
|
||||||
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
|
|
||||||
|
filter({
|
||||||
|
searchParams: newSearch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaginationChange = (newPage: PaginationState) => {
|
||||||
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
||||||
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
||||||
|
searchParams.set("query", query);
|
||||||
|
searchParams.delete("page");
|
||||||
|
filter({
|
||||||
|
searchParams
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -478,20 +517,25 @@ export default function MachineClientsTable({
|
|||||||
title="Delete Client"
|
title="Delete Client"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={machineClients || []}
|
rows={machineClients}
|
||||||
persistPageSize="machine-clients"
|
tableId="machine-clients"
|
||||||
searchPlaceholder={t("resourcesSearch")}
|
searchPlaceholder={t("resourcesSearch")}
|
||||||
searchColumn="name"
|
|
||||||
onAdd={() =>
|
onAdd={() =>
|
||||||
router.push(`/${orgId}/settings/clients/machine/create`)
|
startNavigation(() =>
|
||||||
|
router.push(`/${orgId}/settings/clients/machine/create`)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
pagination={pagination}
|
||||||
|
rowCount={rowCount}
|
||||||
addButtonText={t("createClient")}
|
addButtonText={t("createClient")}
|
||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing || isFiltering}
|
||||||
enableColumnVisibility={true}
|
onSearch={handleSearchChange}
|
||||||
persistColumnVisibility="machine-clients"
|
onPaginationChange={handlePaginationChange}
|
||||||
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
||||||
|
enableColumnVisibility
|
||||||
columnVisibility={defaultMachineColumnVisibility}
|
columnVisibility={defaultMachineColumnVisibility}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -518,30 +562,10 @@ export default function MachineClientsTable({
|
|||||||
value: "blocked"
|
value: "blocked"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
filterFn: (
|
onValueChange(selectedValues: string[]) {
|
||||||
row: ClientRow,
|
handleFilterChange("status", selectedValues);
|
||||||
selectedValues: (string | number | boolean)[]
|
|
||||||
) => {
|
|
||||||
if (selectedValues.length === 0) return true;
|
|
||||||
const rowArchived = row.archived || false;
|
|
||||||
const rowBlocked = row.blocked || false;
|
|
||||||
const isActive = !rowArchived && !rowBlocked;
|
|
||||||
|
|
||||||
if (selectedValues.includes("active") && isActive)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("archived") &&
|
|
||||||
rowArchived
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
if (
|
|
||||||
selectedValues.includes("blocked") &&
|
|
||||||
rowBlocked
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
defaultValues: ["active"] // Default to showing active clients
|
values: searchParams.getAll("status")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function OrgSelector({
|
|||||||
<PopoverContent className="w-[320px] p-0" align="start">
|
<PopoverContent className="w-[320px] p-0" align="start">
|
||||||
<Command className="rounded-lg">
|
<Command className="rounded-lg">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t("searchProgress")}
|
placeholder={t("searchPlaceholder")}
|
||||||
className="border-0 focus:ring-0"
|
className="border-0 focus:ring-0"
|
||||||
/>
|
/>
|
||||||
<CommandEmpty className="py-6 text-center">
|
<CommandEmpty className="py-6 text-center">
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { Laptop, LogOut, Moon, Sun, Smartphone } from "lucide-react";
|
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { build } from "@server/build";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
@@ -187,6 +189,20 @@ export default function ProfileIcon() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link
|
||||||
|
href="/auth/delete-account"
|
||||||
|
className="flex cursor-pointer items-center"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("deleteAccount")}</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={() => logout()}>
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
{/* <LogOut className="mr-2 h-4 w-4" /> */}
|
||||||
<span>{t("logout")}</span>
|
<span>{t("logout")}</span>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user