Compare commits

...

72 Commits

Author SHA1 Message Date
dependabot[bot]
5f990a6830 Bump the prod-patch-updates group across 1 directory with 14 updates
Bumps the prod-patch-updates group with 14 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@react-email/components](https://github.com/resend/react-email/tree/HEAD/packages/components) | `1.0.2` | `1.0.7` |
| [@react-email/render](https://github.com/resend/react-email/tree/HEAD/packages/render) | `2.0.0` | `2.0.4` |
| [@react-email/tailwind](https://github.com/resend/react-email/tree/HEAD/packages/tailwind) | `2.0.2` | `2.0.4` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.90.12` | `5.90.20` |
| [axios](https://github.com/axios/axios) | `1.13.2` | `1.13.5` |
| [cors](https://github.com/expressjs/cors) | `2.8.5` | `2.8.6` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.1.0` | `16.1.6` |
| [glob](https://github.com/isaacs/node-glob) | `13.0.0` | `13.0.1` |
| [maxmind](https://github.com/runk/node-maxmind) | `5.0.1` | `5.0.5` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.3` | `19.2.4` |
| [react-day-picker](https://github.com/gpbl/react-day-picker) | `9.13.0` | `9.13.1` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.3` | `19.2.4` |
| [semver](https://github.com/npm/node-semver) | `7.7.3` | `7.7.4` |
| [zod](https://github.com/colinhacks/zod) | `4.3.5` | `4.3.6` |



Updates `@react-email/components` from 1.0.2 to 1.0.7
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/components/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/components@1.0.7/packages/components)

Updates `@react-email/render` from 2.0.0 to 2.0.4
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/render/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/render@2.0.4/packages/render)

Updates `@react-email/tailwind` from 2.0.2 to 2.0.4
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/tailwind/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/tailwind@2.0.4/packages/tailwind)

Updates `@tanstack/react-query` from 5.90.12 to 5.90.20
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.90.20/packages/react-query)

Updates `axios` from 1.13.2 to 1.13.5
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.13.5)

Updates `cors` from 2.8.5 to 2.8.6
- [Release notes](https://github.com/expressjs/cors/releases)
- [Changelog](https://github.com/expressjs/cors/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/cors/compare/v2.8.5...v2.8.6)

Updates `eslint-config-next` from 16.1.0 to 16.1.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.6/packages/eslint-config-next)

Updates `glob` from 13.0.0 to 13.0.1
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v13.0.0...v13.0.1)

Updates `maxmind` from 5.0.1 to 5.0.5
- [Release notes](https://github.com/runk/node-maxmind/releases)
- [Commits](https://github.com/runk/node-maxmind/compare/v5.0.1...v5.0.5)

Updates `react` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react)

Updates `react-day-picker` from 9.13.0 to 9.13.1
- [Release notes](https://github.com/gpbl/react-day-picker/releases)
- [Changelog](https://github.com/gpbl/react-day-picker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gpbl/react-day-picker/compare/v9.13.0...v9.13.1)

Updates `react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.4/packages/react-dom)

Updates `semver` from 7.7.3 to 7.7.4
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.7.3...v7.7.4)

Updates `zod` from 4.3.5 to 4.3.6
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.5...v4.3.6)

---
updated-dependencies:
- dependency-name: "@react-email/components"
  dependency-version: 1.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@react-email/render"
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@react-email/tailwind"
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.90.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: axios
  dependency-version: 1.13.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: cors
  dependency-version: 2.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: eslint-config-next
  dependency-version: 16.1.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: glob
  dependency-version: 13.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: maxmind
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-day-picker
  dependency-version: 9.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: semver
  dependency-version: 7.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: zod
  dependency-version: 4.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-09 01:41:50 +00:00
Owen
b4c01349d1 Merge branch 'dev' 2026-02-04 21:44:07 -08:00
Owen
165bbd3584 Merge branch 'self-serve' into dev 2026-02-04 21:42:32 -08:00
Lokowitz
ffb253e0e9 fix dockerfile 2026-02-04 21:42:10 -08:00
Owen
e5e9fe456f Add note about the self serve 2026-02-04 21:37:16 -08:00
miloschwartz
c63589b204 auto open checkout modal 2026-02-04 21:31:46 -08:00
miloschwartz
11408c2656 add internal redirect 2026-02-04 21:16:59 -08:00
Owen
7d4aed8819 Add prod price ids 2026-02-04 20:37:25 -08:00
miloschwartz
508369a59d adjust language in form 2026-02-04 20:25:20 -08:00
Owen
26a91cd5e1 Add link 2026-02-04 18:29:35 -08:00
Owen
48dd4d5913 Billing licenses working 2026-02-04 18:15:46 -08:00
Owen
72d46b7352 Moving to supporting more than one sub 2026-02-04 17:54:29 -08:00
Owen
4613aae47d Handle license lifecycle 2026-02-04 17:37:31 -08:00
Owen
1bc4480d84 Working on complete auth flow 2026-02-04 16:32:53 -08:00
miloschwartz
b5d76f73e8 add new tier select form 2026-02-04 16:08:47 -08:00
Owen
a5c7913e77 Checkout flow works 2026-02-04 15:49:49 -08:00
miloschwartz
34b914f509 add license email 2026-02-04 15:38:02 -08:00
miloschwartz
5a3d75ca12 add quantity check 2026-02-04 15:19:58 -08:00
Owen
158d7b23d8 Add test button to launch stripe 2026-02-04 14:13:25 -08:00
Owen
bf5dd3b0a1 Pull secrets from env vars 2026-02-02 21:39:18 -08:00
Owen
e4d4c62833 Dont create newt sites with exit node or subnet 2026-02-02 18:19:13 -08:00
Owen
20ae903d7f Subscribed limits for domains is higher 2026-02-02 16:46:48 -08:00
Owen
f5f757e4bd Subscribed limits for domains is higher 2026-02-02 16:45:54 -08:00
Owen
5ad564d21b Use rand 2026-02-02 10:25:14 -08:00
miloschwartz
8f8775cb93 override device name with computed device name on register 2026-02-01 17:37:18 -08:00
miloschwartz
37695827aa show user display name on device page 2026-02-01 17:30:05 -08:00
miloschwartz
7a72d209ea add --network host to newt install command for docker run 2026-02-01 17:24:16 -08:00
MoweME
b0566d3c6f fix(i18n): correct German site terminology
Updates the German translation to use "Standort" (site) instead of "Seite" (page) for consistency with the site context.
2026-01-29 10:01:30 -08:00
MoweME
5dda8c384f fix(i18n): correct German translation strings
Corrects mistranslation of device timestamp labels and fixes product name reference in site tunnel settings.
2026-01-29 10:01:30 -08:00
Lokowitz
873408270e removed unused gomod code 2026-01-28 15:06:23 -08:00
Lokowitz
8fec8f35bc removed unused code 2026-01-28 15:06:23 -08:00
Owen
141c846fe2 Properly insert PANGOLIN_SETUP_TOKEN into db
Fixes #2361
2026-01-28 15:04:17 -08:00
Owen
cb569ff14d Properly insert PANGOLIN_SETUP_TOKEN into db
Fixes #2361
2026-01-28 15:03:31 -08:00
Lokowitz
1497469016 revert format:write 2026-01-28 14:50:42 -08:00
Lokowitz
e356a6d33b fix lable error and make dockerfile readable 2026-01-28 14:50:42 -08:00
miloschwartz
12aea2901d fix depreated zod warning 2026-01-26 14:11:03 -08:00
miloschwartz
5ff56467ea error response improvements to logo url 2026-01-26 14:00:22 -08:00
miloschwartz
3a8718a4b0 remove archive confirmtion on account devices dialog 2026-01-26 13:36:25 -08:00
Owen
37c4a7b690 Retry verify 2026-01-24 11:55:32 -08:00
Owen
b735e7c34d Fix #2314 2026-01-24 11:47:17 -08:00
Owen
5f85c3b3b8 Remove extra rebuild command 2026-01-24 11:35:45 -08:00
miloschwartz
5d9cb9fa21 fix clear olmId from client on archive 2026-01-24 11:11:25 -08:00
miloschwartz
643d56958d fix saas private import 2026-01-23 10:07:05 -08:00
miloschwartz
f378d6f040 fix input border 2026-01-22 21:24:28 -08:00
Milo Schwartz
bb57794388 Merge pull request #2306 from Fredkiss3/fix/tab-from-host-port
fix: tab between host & port in resource target address column
2026-01-22 21:14:20 -08:00
miloschwartz
a9ca49b8a2 Merge branch 'main' into dev 2026-01-22 21:10:40 -08:00
Fred KISSIE
c1b473294e 🔥 remove useless useEffect 2026-01-23 04:54:24 +01:00
Fred KISSIE
e3e4bdfe09 🚸 fix target item tabbing by memoizing the getColumns (and its dependencies) 2026-01-23 04:40:19 +01:00
miloschwartz
bfbeace2e2 fix import in list approvals 2026-01-22 17:54:53 -08:00
miloschwartz
efcf46ce8a fix policy check on olm register 2026-01-22 16:28:15 -08:00
miloschwartz
2085715965 fix wrong redirect url when idp login with custom auth domain 2026-01-22 15:46:48 -08:00
Owen
d227db7b7b Show the source in the UI 2026-01-22 15:18:27 -08:00
Owen
2af67ad355 Fix the source of the cli blueprint 2026-01-22 15:18:27 -08:00
miloschwartz
f100854423 add ios and android to readme 2026-01-22 15:18:27 -08:00
miloschwartz
92331d7a33 clean up paid features check 2026-01-22 15:18:27 -08:00
Owen
9a5bcb9099 Hiring 2026-01-22 15:18:27 -08:00
miloschwartz
8eb6bb2a95 dont include posture in repsonse if not licensed or subscribed 2026-01-22 15:18:27 -08:00
miloschwartz
2aa65ccab3 add mobile links to download banner 2026-01-22 15:18:27 -08:00
miloschwartz
be1577a3e7 remove biometric support from ios 2026-01-22 15:18:27 -08:00
miloschwartz
c8e1b3bf29 rename windowsDefenderEnabled 2026-01-22 15:18:27 -08:00
Owen
e17b986628 Dont show bio info on android 2026-01-22 15:18:27 -08:00
Owen
5f19918ca0 Show the source in the UI 2026-01-22 15:16:41 -08:00
Owen
2959ad0e70 Fix the source of the cli blueprint 2026-01-22 15:03:04 -08:00
miloschwartz
a76eec7bb7 add ios and android to readme 2026-01-22 11:27:24 -08:00
miloschwartz
068b2a0dcd clean up paid features check 2026-01-22 11:16:27 -08:00
Owen
316b7e5653 Hiring 2026-01-22 10:38:32 -08:00
miloschwartz
00fc1da33c dont include posture in repsonse if not licensed or subscribed 2026-01-22 10:36:52 -08:00
miloschwartz
9ef93df54f add mobile links to download banner 2026-01-21 18:16:16 -08:00
miloschwartz
fd9fdf6399 remove biometric support from ios 2026-01-21 18:13:12 -08:00
miloschwartz
8fa1701e06 rename windowsDefenderEnabled 2026-01-21 17:57:20 -08:00
Owen
4abe83f8a9 Dont show bio info on android 2026-01-21 16:36:35 -08:00
Owen
0a7564acb6 Fix not detecting rc release in sign and package 2026-01-21 16:14:00 -08:00
109 changed files with 3403 additions and 3973 deletions

View File

@@ -44,19 +44,9 @@ updates:
schedule:
interval: "daily"
groups:
dev-patch-updates:
dependency-type: "development"
patch-updates:
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
minor-updates:
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"

View File

@@ -339,37 +339,37 @@ jobs:
TAG=${{ env.TAG }}
MAJOR_TAG=$(echo $TAG | cut -d. -f1)
MINOR_TAG=$(echo $TAG | cut -d. -f1,2)
echo "Waiting for multi-arch manifests to be ready..."
sleep 30
# Determine if this is an RC release
IS_RC="false"
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
if [[ "$TAG" == *"-rc."* ]]; then
IS_RC="true"
fi
if [ "$IS_RC" = "true" ]; then
echo "RC release detected - copying version-specific tags only"
# SQLite OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:$TAG \
docker://$GHCR_IMAGE:$TAG
# PostgreSQL OSS
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:postgresql-$TAG \
docker://$GHCR_IMAGE:postgresql-$TAG
# SQLite Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}"
skopeo copy --all --retry-times 3 \
docker://$DOCKERHUB_IMAGE:ee-$TAG \
docker://$GHCR_IMAGE:ee-$TAG
# PostgreSQL Enterprise
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}"
skopeo copy --all --retry-times 3 \
@@ -377,7 +377,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG
else
echo "Regular release detected - copying all tags (latest, major, minor, full version)"
# SQLite OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}"
@@ -385,7 +385,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \
docker://$GHCR_IMAGE:$TAG_SUFFIX
done
# PostgreSQL OSS - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}"
@@ -393,7 +393,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \
docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX
done
# SQLite Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}"
@@ -401,7 +401,7 @@ jobs:
docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \
docker://$GHCR_IMAGE:ee-$TAG_SUFFIX
done
# PostgreSQL Enterprise - all tags
for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do
echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}"
@@ -410,7 +410,7 @@ jobs:
docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX
done
fi
echo "All images copied successfully to GHCR!"
shell: bash
@@ -442,7 +442,7 @@ jobs:
# Determine if this is an RC release
IS_RC="false"
if echo "$TAG" | grep -qE "rc[0-9]+$"; then
if [[ "$TAG" == *"-rc."* ]]; then
IS_RC="true"
fi
@@ -482,19 +482,82 @@ jobs:
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
cosign verify \
--certificate-oidc-issuer "${issuer}" \
--certificate-identity-regexp "${id_regex}" \
"${REF}" -o text
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# If index verification fails, attempt to verify child platform manifests
if [ "${VERIFIED_INDEX}" != "true" ] || [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "Index verification not available; attempting child manifest verification for ${BASE_IMAGE}:${IMAGE_TAG}"
CHILD_VERIFIED=false
for ARCH in arm64 amd64; do
CHILD_TAG="${IMAGE_TAG}-${ARCH}"
echo "Resolving child digest for ${BASE_IMAGE}:${CHILD_TAG}"
CHILD_DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${CHILD_TAG} | jq -r '.Digest' || true)"
if [ -n "${CHILD_DIGEST}" ] && [ "${CHILD_DIGEST}" != "null" ]; then
CHILD_REF="${BASE_IMAGE}@${CHILD_DIGEST}"
echo "==> cosign verify (public key) child ${CHILD_REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Public key verification succeeded for child ${CHILD_REF}"
else
echo "Public key verification failed for child ${CHILD_REF}"
fi
echo "==> cosign verify (keyless policy) child ${CHILD_REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${CHILD_REF}' -o text"; then
CHILD_VERIFIED=true
echo "Keyless verification succeeded for child ${CHILD_REF}"
else
echo "Keyless verification failed for child ${CHILD_REF}"
fi
else
echo "No child digest found for ${BASE_IMAGE}:${CHILD_TAG}; skipping"
fi
done
if [ "${CHILD_VERIFIED}" != "true" ]; then
echo "Failed to verify index and no child manifests verified for ${BASE_IMAGE}:${IMAGE_TAG}"
exit 10
fi
fi
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}"
done
done
echo "All images signed and verified successfully!"
shell: bash

View File

@@ -26,7 +26,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
node-version: '24'
- name: Install dependencies
run: npm ci

View File

@@ -19,7 +19,7 @@ jobs:
- name: Install Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: '22'
node-version: '24'
- name: Copy config file
run: cp config/config.example.yml config/config.yml
@@ -34,7 +34,7 @@ jobs:
run: npm run set:oss
- name: Generate database migrations
run: npm run db:sqlite:generate
run: npm run db:generate
- name: Apply database migrations
run: npm run db:sqlite:push
@@ -64,9 +64,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -76,8 +73,5 @@ jobs:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image pg
run: make dev-build-pg

View File

@@ -4,13 +4,13 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.json-language-features"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
@@ -19,4 +19,4 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true
}
}

View File

@@ -1,21 +1,11 @@
FROM node:24-alpine AS builder
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
WORKDIR /app
ARG BUILD=oss
ARG DATABASE=sqlite
# Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin"
ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere"
RUN apk add --no-cache curl tzdata python3 make g++
RUN apk add --no-cache python3 make g++
# COPY package.json package-lock.json ./
COPY package*.json ./
@@ -23,41 +13,31 @@ RUN npm ci
COPY . .
RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts
RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts
RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts
# Copy the appropriate TypeScript configuration based on build type
RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \
elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \
elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \
fi
# if the build is oss then remove the server/private directory
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi
RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi
RUN mkdir -p dist
RUN npm run next:build
RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD
RUN if [ "$DATABASE" = "pg" ]; then \
node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \
else \
node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \
fi
RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \
npm run set:$DATABASE && \
npm run set:$BUILD && \
npm run db:generate && \
npm run build && \
npm run build:cli
# test to make sure the build output is there and error if not
RUN test -f dist/server.mjs
RUN npm run build:cli
# Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner
# OCI Image Labels - Build Args for dynamic values
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
ARG LICENSE="AGPL-3.0"
# Derive title and description based on BUILD type
ARG IMAGE_TITLE="Pangolin"
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!
@@ -66,11 +46,10 @@ 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/init ./dist/init
COPY --from=builder /app/server/migrations ./dist/init
COPY --from=builder /app/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl

View File

@@ -35,6 +35,12 @@
</div>
<p align="center">
<a href="https://docs.pangolin.net/careers/join-us">
<img src="https://img.shields.io/badge/🚀_We're_Hiring!-Join_Our_Team-brightgreen?style=for-the-badge" alt="We're Hiring!" />
</a>
</p>
<p align="center">
<strong>
Start testing Pangolin at <a href="https://app.pangolin.net/auth/signup">app.pangolin.net</a>
@@ -74,6 +80,8 @@ Download the Pangolin client for your platform:
- [Mac](https://pangolin.net/downloads/mac)
- [Windows](https://pangolin.net/downloads/windows)
- [Linux](https://pangolin.net/downloads/linux)
- [iOS](https://pangolin.net/downloads/ios)
- [Android](https://pangolin.net/downloads/android)
## Get Started

View File

@@ -1,72 +0,0 @@
import requests
import yaml
import json
import base64
# The file path for the YAML file to be read
# You can change this to the path of your YAML file
YAML_FILE_PATH = 'blueprint.yaml'
# The API endpoint and headers from the curl request
API_URL = 'http://api.pangolin.net/v1/org/test/blueprint'
HEADERS = {
'accept': '*/*',
'Authorization': 'Bearer <your_token_here>',
'Content-Type': 'application/json'
}
def convert_and_send(file_path, url, headers):
"""
Reads a YAML file, converts its content to a JSON payload,
and sends it via a PUT request to a specified URL.
"""
try:
# Read the YAML file content
with open(file_path, 'r') as file:
yaml_content = file.read()
# Parse the YAML string to a Python dictionary
# This will be used to ensure the YAML is valid before sending
parsed_yaml = yaml.safe_load(yaml_content)
# convert the parsed YAML to a JSON string
json_payload = json.dumps(parsed_yaml)
print("Converted JSON payload:")
print(json_payload)
# Encode the JSON string to Base64
encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8')
# Create the final payload with the base64 encoded data
final_payload = {
"blueprint": encoded_json
}
print("Sending the following Base64 encoded JSON payload:")
print(final_payload)
print("-" * 20)
# Make the PUT request with the base64 encoded payload
response = requests.put(url, headers=headers, json=final_payload)
# Print the API response for debugging
print(f"API Response Status Code: {response.status_code}")
print("API Response Content:")
print(response.text)
# Raise an exception for bad status codes (4xx or 5xx)
response.raise_for_status()
except FileNotFoundError:
print(f"Error: The file '{file_path}' was not found.")
except yaml.YAMLError as e:
print(f"Error parsing YAML file: {e}")
except requests.exceptions.RequestException as e:
print(f"An error occurred during the API request: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
# Run the function
if __name__ == "__main__":
convert_and_send(YAML_FILE_PATH, API_URL, HEADERS)

View File

@@ -1,70 +0,0 @@
client-resources:
client-resource-nice-id-uno:
name: this is my resource
protocol: tcp
proxy-port: 3001
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
client-resource-nice-id-duce:
name: this is my resource
protocol: udp
proxy-port: 3000
hostname: localhost
internal-port: 3000
site: lively-yosemite-toad
proxy-resources:
resource-nice-id-uno:
name: this is my resource
protocol: http
full-domain: duce.test.example.com
host-header: example.com
tls-server-name: example.com
# auth:
# pincode: 123456
# password: sadfasdfadsf
# sso-enabled: true
# sso-roles:
# - Member
# sso-users:
# - owen@pangolin.net
# whitelist-users:
# - owen@pangolin.net
# auto-login-idp: 1
headers:
- name: X-Example-Header
value: example-value
- name: X-Another-Header
value: another-value
rules:
- action: allow
match: ip
value: 1.1.1.1
- action: deny
match: cidr
value: 2.2.2.2/32
- action: pass
match: path
value: /admin
targets:
- site: lively-yosemite-toad
path: /path
pathMatchType: prefix
hostname: localhost
method: http
port: 8000
- site: slim-alpine-chipmunk
hostname: localhost
path: /yoman
pathMatchType: exact
method: http
port: 8001
resource-nice-id-duce:
name: this is other resource
protocol: tcp
proxy-port: 3000
targets:
- site: lively-yosemite-toad
hostname: localhost
port: 3000

View File

@@ -6,6 +6,12 @@ import path from "path";
import fs from "fs";
// import { glob } from "glob";
// Read default build type from server/build.ts
let build = "oss";
const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8");
const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/);
if (m) build = m[1];
const banner = `
// patch __dirname
// import { fileURLToPath } from "url";
@@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv))
describe: "Build type (oss, saas, enterprise)",
type: "string",
choices: ["oss", "saas", "enterprise"],
default: "oss"
default: build
})
.help()
.alias("help", "h").argv;

View File

@@ -6,7 +6,8 @@ import (
"fmt"
"io"
"io/fs"
"math/rand"
"crypto/rand"
"encoding/base64"
"net"
"net/http"
"net/url"
@@ -592,17 +593,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai
}
func generateRandomSecretKey() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const length = 32
var seededRand *rand.Rand = rand.New(
rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
secret := make([]byte, 32)
_, err := rand.Read(secret)
if err != nil {
panic(fmt.Sprintf("Failed to generate random secret key: %v", err))
}
return string(b)
return base64.StdEncoding.EncodeToString(secret)
}
func getPublicIP() string {

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Активирана защитна стена.",
"autoUpdatesEnabled": "Активирани автоматични актуализации.",
"tpmAvailable": "TPM е на разположение.",
"windowsDefenderEnabled": "Windows Defender е активиран.",
"macosSipEnabled": "Protection на системната цялост (SIP).",
"macosGatekeeperEnabled": "Gatekeeper.",
"macosFirewallStealthMode": "Скрит режим на защитната стена.",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall povolen",
"autoUpdatesEnabled": "Automatické aktualizace povoleny",
"tpmAvailable": "TPM k dispozici",
"windowsDefenderEnabled": "Okna byla povolena",
"macosSipEnabled": "Ochrana systémové integrity (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Režim neviditelnosti firewallu",

View File

@@ -97,7 +97,7 @@
"siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren",
"siteSettingDescription": "Standorteinstellungen konfigurieren",
"siteSetting": "{siteName} Einstellungen",
"siteNewtTunnel": "Neuer Standort (empfohlen)",
"siteNewtTunnel": "Newt Standort (empfohlen)",
"siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.",
"siteWg": "Einfacher WireGuard Tunnel",
"siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.",
@@ -107,7 +107,7 @@
"siteSeeAll": "Alle Standorte anzeigen",
"siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten",
"siteNewtCredentials": "Zugangsdaten",
"siteNewtCredentialsDescription": "So wird sich die Seite mit dem Server authentifizieren",
"siteNewtCredentialsDescription": "So wird sich der Standort mit dem Server authentifizieren",
"remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren",
"siteCredentialsSave": "Anmeldedaten speichern",
"siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.",
@@ -2503,14 +2503,13 @@
"deviceModel": "Gerätemodell",
"serialNumber": "Seriennummer",
"hostname": "Hostname",
"firstSeen": "Erster Blick",
"firstSeen": "Zuerst gesehen",
"lastSeen": "Zuletzt gesehen",
"biometricsEnabled": "Biometrie aktiviert",
"diskEncrypted": "Festplatte verschlüsselt",
"firewallEnabled": "Firewall aktiviert",
"autoUpdatesEnabled": "Automatische Updates aktiviert",
"tpmAvailable": "TPM verfügbar",
"windowsDefenderEnabled": "Windows Defender aktiviert",
"macosSipEnabled": "Schutz der Systemintegrität (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth-Modus",

View File

@@ -1436,6 +1436,15 @@
"billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.",
"billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.",
"billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.",
"billingLicenseKeys": "License Keys",
"billingLicenseKeysDescription": "Manage your license key subscriptions",
"billingLicenseSubscription": "License Subscription",
"billingInactive": "Inactive",
"billingLicenseItem": "License Item",
"billingQuantity": "Quantity",
"billingTotal": "total",
"billingModifyLicenses": "Modify License Subscription",
"billingPricingCalculatorLink": "View Pricing Calculator",
"domainNotFound": "Domain Not Found",
"domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed",
@@ -2113,6 +2122,32 @@
}
}
},
"newPricingLicenseForm": {
"title": "Get a license",
"description": "Choose a plan and tell us how you plan to use Pangolin.",
"chooseTier": "Choose your plan",
"viewPricingLink": "See pricing, features, and limits",
"tiers": {
"starter": {
"title": "Starter",
"description": "Enterprise features, 25 users, 25 sites, and community support."
},
"scale": {
"title": "Scale",
"description": "Enterprise features, 50 users, 50 sites, and priority support."
}
},
"personalUseOnly": "Personal use only (free license — no checkout)",
"buttons": {
"continueToCheckout": "Continue to Checkout"
},
"toasts": {
"checkoutError": {
"title": "Checkout error",
"description": "Could not start checkout. Please try again."
}
}
},
"priority": "Priority",
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
"instanceName": "Instance Name",
@@ -2510,7 +2545,7 @@
"firewallEnabled": "Firewall Enabled",
"autoUpdatesEnabled": "Auto Updates Enabled",
"tpmAvailable": "TPM Available",
"windowsDefenderEnabled": "Windows Defender Enabled",
"windowsAntivirusEnabled": "Antivirus Enabled",
"macosSipEnabled": "System Integrity Protection (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Stealth Mode",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Cortafuegos activado",
"autoUpdatesEnabled": "Actualizaciones automáticas habilitadas",
"tpmAvailable": "TPM disponible",
"windowsDefenderEnabled": "Windows Defender activado",
"macosSipEnabled": "Protección de integridad del sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Sigilo Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Pare-feu activé",
"autoUpdatesEnabled": "Mises à jour automatiques activées",
"tpmAvailable": "TPM disponible",
"windowsDefenderEnabled": "Windows Defender activé",
"macosSipEnabled": "Protection contre l'intégrité du système (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Mode furtif du pare-feu",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall Abilitato",
"autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati",
"tpmAvailable": "TPM Disponibile",
"windowsDefenderEnabled": "Windows Defender Abilitato",
"macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo Del Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "방화벽 활성화",
"autoUpdatesEnabled": "자동 업데이트 활성화",
"tpmAvailable": "TPM 사용 가능",
"windowsDefenderEnabled": "Windows Defender 활성화",
"macosSipEnabled": "시스템 무결성 보호 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "방화벽 스텔스 모드",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Brannmur aktivert",
"autoUpdatesEnabled": "Automatiske oppdateringer aktivert",
"tpmAvailable": "TPM tilgjengelig",
"windowsDefenderEnabled": "Windows svarer aktivert",
"macosSipEnabled": "System Integritetsbeskyttelse (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Brannmur Usynlig Modus",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall ingeschakeld",
"autoUpdatesEnabled": "Auto Updates Ingeschakeld",
"tpmAvailable": "TPM beschikbaar",
"windowsDefenderEnabled": "Windows Verdediger ingeschakeld",
"macosSipEnabled": "Systeemintegriteitsbescherming (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Firewall Verberg Modus",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Zapora włączona",
"autoUpdatesEnabled": "Automatyczne aktualizacje włączone",
"tpmAvailable": "TPM dostępne",
"windowsDefenderEnabled": "Obrońca Windows włączony",
"macosSipEnabled": "Ochrona integralności systemu (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Tryb Stealth zapory",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Firewall habilitado",
"autoUpdatesEnabled": "Atualizações Automáticas Habilitadas",
"tpmAvailable": "TPM disponível",
"windowsDefenderEnabled": "Defensor do Windows habilitado",
"macosSipEnabled": "Proteção da Integridade do Sistema (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Modo Furtivo do Firewall",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Брандмауэр включен",
"autoUpdatesEnabled": "Автоматические обновления включены",
"tpmAvailable": "Доступно TPM",
"windowsDefenderEnabled": "Защитник Windows включен",
"macosSipEnabled": "Защита целостности системы (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Стилс-режим брандмауэра",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "Güvenlik Duvarı Etkin",
"autoUpdatesEnabled": "Otomatik Güncellemeler Etkin",
"tpmAvailable": "TPM Mevcut",
"windowsDefenderEnabled": "Windows Defender Etkin",
"macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu",

View File

@@ -2510,7 +2510,6 @@
"firewallEnabled": "防火墙已启用",
"autoUpdatesEnabled": "启用自动更新",
"tpmAvailable": "TPM 可用",
"windowsDefenderEnabled": "Windows Defender 已启用",
"macosSipEnabled": "系统完整性保护 (SIP)",
"macosGatekeeperEnabled": "Gatekeeper",
"macosFirewallStealthMode": "防火墙隐形模式",

2693
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,24 +12,24 @@
"license": "SEE LICENSE IN LICENSE AND README.md",
"scripts": {
"dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts",
"db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts",
"dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:generate && npm run db:sqlite:push",
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
"db:pg:push": "npx tsx server/db/pg/migrate.ts",
"db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts",
"db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts",
"db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
"db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",
"set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json",
"set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json",
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts",
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts",
"next:build": "next build",
"build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs",
"build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs",
"set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts",
"set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts",
"build:next": "next build",
"build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs",
"start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3005",
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
"format:check": "prettier --check .",
"format": "prettier --write ."
},
"dependencies": {
@@ -60,69 +60,62 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/components": "1.0.2",
"@react-email/render": "2.0.0",
"@react-email/tailwind": "2.0.2",
"@react-email/components": "1.0.7",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.4",
"@simplewebauthn/browser": "13.2.2",
"@simplewebauthn/server": "13.2.2",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.12",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-table": "8.21.3",
"arctic": "3.7.0",
"axios": "1.13.2",
"axios": "1.13.5",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"cookie": "1.1.1",
"cookie-parser": "1.4.7",
"cookies": "0.9.1",
"cors": "2.8.5",
"cors": "2.8.6",
"crypto-js": "4.2.0",
"d3": "7.9.0",
"date-fns": "4.1.0",
"drizzle-orm": "0.45.1",
"eslint": "9.39.2",
"eslint-config-next": "16.1.0",
"eslint-config-next": "16.1.6",
"express": "5.2.1",
"express-rate-limit": "8.2.1",
"glob": "13.0.0",
"glob": "13.0.1",
"helmet": "8.1.0",
"http-errors": "2.0.1",
"i": "0.3.7",
"input-otp": "1.4.2",
"ioredis": "5.9.2",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "0.562.0",
"maxmind": "5.0.1",
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "15.5.9",
"next-intl": "4.7.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "7.0.11",
"npm": "11.7.0",
"nprogress": "0.2.0",
"oslo": "1.2.1",
"pg": "8.17.1",
"posthog-node": "5.23.0",
"qrcode.react": "4.2.0",
"react": "19.2.3",
"react-day-picker": "9.13.0",
"react-dom": "19.2.3",
"react": "19.2.4",
"react-day-picker": "9.13.1",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.1",
"react-icons": "5.5.0",
"rebuild": "0.1.2",
"recharts": "2.15.4",
"reodotdev": "1.0.0",
"resend": "6.8.0",
"semver": "7.7.3",
"semver": "7.7.4",
"stripe": "20.2.0",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.4.0",
@@ -136,7 +129,7 @@
"ws": "8.19.0",
"yaml": "2.8.2",
"yargs": "18.0.0",
"zod": "4.3.5",
"zod": "4.3.6",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
@@ -157,7 +150,7 @@
"@types/nodemailer": "7.0.4",
"@types/nprogress": "0.2.3",
"@types/pg": "8.16.0",
"@types/react": "19.2.7",
"@types/react": "19.2.13",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/swagger-ui-express": "4.1.8",

View File

@@ -778,7 +778,7 @@ export const currentFingerprint = pgTable("currentFingerprint", {
// Windows-specific posture check information
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
.notNull()
.default(false),
@@ -830,7 +830,7 @@ export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
// Windows-specific posture check information
windowsDefenderEnabled: boolean("windowsDefenderEnabled")
windowsAntivirusEnabled: boolean("windowsAntivirusEnabled")
.notNull()
.default(false),

View File

@@ -475,7 +475,7 @@ export const currentFingerprint = sqliteTable("currentFingerprint", {
// Windows-specific posture check information
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
mode: "boolean"
})
.notNull()
@@ -549,7 +549,7 @@ export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
// Windows-specific posture check information
windowsDefenderEnabled: integer("windowsDefenderEnabled", {
windowsAntivirusEnabled: integer("windowsAntivirusEnabled", {
mode: "boolean"
})
.notNull()

View File

@@ -0,0 +1,118 @@
import React from "react";
import { Body, Head, Html, Preview, Tailwind } from "@react-email/components";
import { themeColors } from "./lib/theme";
import {
EmailContainer,
EmailFooter,
EmailGreeting,
EmailHeading,
EmailInfoSection,
EmailLetterHead,
EmailSection,
EmailSignature,
EmailText
} from "./components/Email";
import CopyCodeBox from "./components/CopyCodeBox";
import ButtonLink from "./components/ButtonLink";
type EnterpriseEditionKeyGeneratedProps = {
keyValue: string;
personalUseOnly: boolean;
users: number;
sites: number;
modifySubscriptionLink?: string;
};
export const EnterpriseEditionKeyGenerated = ({
keyValue,
personalUseOnly,
users,
sites,
modifySubscriptionLink
}: EnterpriseEditionKeyGeneratedProps) => {
const previewText = personalUseOnly
? "Your Enterprise Edition key for personal use is ready"
: "Thank you for your purchase — your Enterprise Edition key is ready";
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={themeColors}>
<Body className="font-sans bg-gray-50">
<EmailContainer>
<EmailLetterHead />
<EmailGreeting>Hi there,</EmailGreeting>
{personalUseOnly ? (
<EmailText>
Your Enterprise Edition license key has been
generated. Qualifying users can use the
Enterprise Edition for free for{" "}
<strong>personal use only</strong>.
</EmailText>
) : (
<>
<EmailText>
Thank you for your purchase. Your Enterprise
Edition license key is ready. Below are the
terms of your license.
</EmailText>
<EmailInfoSection
title="License details"
items={[
{
label: "Licensed users",
value: users
},
{
label: "Licensed sites",
value: sites
}
]}
/>
{modifySubscriptionLink && (
<EmailSection>
<ButtonLink
href={modifySubscriptionLink}
>
Modify subscription
</ButtonLink>
</EmailSection>
)}
</>
)}
<EmailSection>
<EmailText>Your license key:</EmailText>
<CopyCodeBox
text={keyValue}
hint="Copy this key and use it when activating Enterprise Edition on your Pangolin host."
/>
</EmailSection>
<EmailText>
If you need to purchase additional license keys or
modify your existing license, please reach out to
our support team at{" "}
<a
href="mailto:support@pangolin.net"
className="text-primary font-medium"
>
support@pangolin.net
</a>
.
</EmailText>
<EmailFooter>
<EmailSignature />
</EmailFooter>
</EmailContainer>
</Body>
</Tailwind>
</Html>
);
};
export default EnterpriseEditionKeyGenerated;

View File

@@ -1,6 +1,14 @@
import React from "react";
export default function CopyCodeBox({ text }: { text: string }) {
const DEFAULT_HINT = "Copy and paste this code when prompted";
export default function CopyCodeBox({
text,
hint
}: {
text: string;
hint?: string;
}) {
return (
<div className="inline-block">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto">
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) {
{text}
</span>
</div>
<p className="text-xs text-gray-500 mt-2">
Copy and paste this code when prompted
</p>
<p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p>
</div>
);
}

View File

@@ -0,0 +1,37 @@
export enum LicenseId {
SMALL_LICENSE = "small_license",
BIG_LICENSE = "big_license"
}
export type LicensePriceSet = {
[key in LicenseId]: string;
};
export const licensePriceSet: LicensePriceSet = {
// Free license matches the freeLimitSet
[LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8",
[LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y"
};
export const licensePriceSetSandbox: LicensePriceSet = {
// Free license matches the freeLimitSet
// when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value
[LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN",
[LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl"
};
export function getLicensePriceSet(
environment?: string,
sandbox_mode?: boolean
): LicensePriceSet {
if (
(process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true") ||
(environment === "prod" && sandbox_mode !== true)
) {
// THIS GETS LOADED CLIENT SIDE AND SERVER SIDE
return licensePriceSet;
} else {
return licensePriceSetSandbox;
}
}

View File

@@ -40,7 +40,7 @@ export const subscribedLimitSet: LimitSet = {
description: "Contact us to increase soft limit."
}, // 12000 GB
[FeatureId.DOMAINS]: {
value: 25,
value: 250,
description: "Contact us to increase soft limit."
},
[FeatureId.REMOTE_EXIT_NODES]: {

View File

@@ -31,7 +31,7 @@ import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "../isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { build } from "@server/build";
export type ProxyResourcesResults = {
@@ -213,11 +213,7 @@ export async function updateProxyResources(
// Update existing resource
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
resourceData.maintenance = undefined;
}
@@ -594,7 +590,7 @@ export async function updateProxyResources(
existingRule.action !== getRuleAction(rule.action) ||
existingRule.match !== rule.match.toUpperCase() ||
existingRule.value !==
getRuleValue(rule.match.toUpperCase(), rule.value) ||
getRuleValue(rule.match.toUpperCase(), rule.value) ||
existingRule.priority !== intendedPriority
) {
validateRule(rule);
@@ -653,11 +649,7 @@ export async function updateProxyResources(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
resourceData.maintenance = undefined;
}

View File

@@ -14,7 +14,7 @@ import {
} from "@server/db";
import { getUniqueClientName } from "@server/db/names";
import { getNextAvailableClientSubnet } from "@server/lib/ip";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import logger from "@server/logger";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";

View File

@@ -0,0 +1,3 @@
export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};

View File

@@ -1,17 +1,3 @@
import { build } from "@server/build";
import license from "#dynamic/license/license";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return true;
}
return false;
}

View File

@@ -3,13 +3,10 @@ import yaml from "js-yaml";
import { configFilePath1, configFilePath2 } from "./consts";
import { z } from "zod";
import stoi from "./stoi";
import { getEnvOrYaml } from "./getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535);
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
export const configSchema = z
.object({
app: z
@@ -311,7 +308,10 @@ export const configSchema = z
.object({
smtp_host: z.string().optional(),
smtp_port: portSchema.optional(),
smtp_user: z.string().optional(),
smtp_user: z
.string()
.optional()
.transform(getEnvOrYaml("EMAIL_SMTP_USER")),
smtp_pass: z
.string()
.optional()

View File

@@ -12,6 +12,10 @@ export type LicenseStatus = {
isLicenseValid: boolean; // Is the license key valid?
hostId: string; // Host ID
tier?: LicenseKeyTier;
maxSites?: number;
usedSites?: number;
maxUsers?: number;
usedUsers?: number;
};
export type LicenseKeyCache = {
@@ -22,12 +26,14 @@ export type LicenseKeyCache = {
type?: LicenseKeyType;
tier?: LicenseKeyTier;
terminateAt?: Date;
quantity?: number;
quantity_2?: number;
};
export class License {
private serverSecret!: string;
constructor(private hostMeta: HostMeta) {}
constructor(private hostMeta: HostMeta) { }
public async check(): Promise<LicenseStatus> {
return {

View File

@@ -12,7 +12,7 @@
*/
import { getTierPriceSet } from "@server/lib/billing/tiers";
import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription";
import { getOrgSubscriptionsData } from "@server/private/routers/billing/getOrgSubscriptions";
import { build } from "@server/build";
export async function getOrgTierData(
@@ -25,22 +25,32 @@ export async function getOrgTierData(
return { tier, active };
}
const { subscription, items } = await getOrgSubscriptionData(orgId);
// TODO: THIS IS INEFFICIENT!!! WE SHOULD IMPROVE HOW WE STORE TIERS WITH SUBSCRIPTIONS AND RETRIEVE THEM
if (items && items.length > 0) {
const tierPriceSet = getTierPriceSet();
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = items.find((item) => item.priceId === priceId);
if (matchingItem) {
tier = tierId;
break;
const subscriptionsWithItems = await getOrgSubscriptionsData(orgId);
for (const { subscription, items } of subscriptionsWithItems) {
if (items && items.length > 0) {
const tierPriceSet = getTierPriceSet();
// Iterate through tiers in order (earlier keys are higher tiers)
for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
// Check if any subscription item matches this tier's price ID
const matchingItem = items.find((item) => item.priceId === priceId);
if (matchingItem) {
tier = tierId;
break;
}
}
}
}
if (subscription && subscription.status === "active") {
active = true;
if (subscription && subscription.status === "active") {
active = true;
}
// If we found a tier and active subscription, we can stop
if (tier && active) {
break;
}
}
return { tier, active };
}

View File

@@ -19,7 +19,6 @@ import * as fs from "fs";
import logger from "@server/logger";
import cache from "@server/lib/cache";
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
@@ -27,15 +26,7 @@ function loadEncryptData() {
return; // already loaded
}
encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}

View File

@@ -0,0 +1,30 @@
/*
* 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 { build } from "@server/build";
import license from "#private/license/license";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
export async function isLicensedOrSubscribed(orgId: string): Promise<boolean> {
if (build === "enterprise") {
return await license.isUnlocked();
}
if (build === "saas") {
const { tier } = await getOrgTierData(orgId);
return tier === TierId.STANDARD;
}
return false;
}

View File

@@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts";
import { z } from "zod";
import { colorsSchema } from "@server/lib/colorsSchema";
import { build } from "@server/build";
import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535);
@@ -32,19 +33,29 @@ export const privateConfigSchema = z.object({
}),
server: z
.object({
encryption_key_path: z
encryption_key: z
.string()
.optional()
.default("./config/encryption.pem")
.pipe(z.string().min(8)),
resend_api_key: z.string().optional(),
reo_client_id: z.string().optional(),
fossorial_api_key: z.string().optional()
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
resend_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("RESEND_API_KEY")),
reo_client_id: z
.string()
.optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z
.string()
.optional()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
})
.optional()
.default({
encryption_key_path: "./config/encryption.pem"
}),
.prefault({}),
redis: z
.object({
host: z.string(),
@@ -157,8 +168,14 @@ export const privateConfigSchema = z.object({
.optional(),
stripe: z
.object({
secret_key: z.string(),
webhook_secret: z.string(),
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")),
s3Bucket: z.string(),
s3Region: z.string().default("us-east-1"),
localFilePath: z.string()

View File

@@ -11,12 +11,12 @@
* This file is not licensed under the AGPLv3.
*/
import { db, HostMeta } from "@server/db";
import { db, HostMeta, sites, users } from "@server/db";
import { hostMeta, licenseKey } from "@server/db";
import logger from "@server/logger";
import NodeCache from "node-cache";
import { validateJWT } from "./licenseJwt";
import { eq } from "drizzle-orm";
import { count, eq } from "drizzle-orm";
import moment from "moment";
import { encrypt, decrypt } from "@server/lib/crypto";
import {
@@ -54,6 +54,7 @@ type TokenPayload = {
type: LicenseKeyType;
tier: LicenseKeyTier;
quantity: number;
quantity_2: number;
terminateAt: string; // ISO
iat: number; // Issued at
};
@@ -140,10 +141,20 @@ LQIDAQAB
};
}
// Count used sites and users for license comparison
const [siteCountRes] = await db
.select({ value: count() })
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId,
isHostLicensed: true,
isLicenseValid: false
isLicenseValid: false,
usedSites: siteCountRes?.value ?? 0,
usedUsers: userCountRes?.value ?? 0
};
this.checkInProgress = true;
@@ -151,6 +162,8 @@ LQIDAQAB
try {
if (!this.doRecheck && this.statusCache.has(this.statusKey)) {
const res = this.statusCache.get("status") as LicenseStatus;
res.usedSites = status.usedSites;
res.usedUsers = status.usedUsers;
return res;
}
logger.debug("Checking license status...");
@@ -193,7 +206,9 @@ LQIDAQAB
type: payload.type,
tier: payload.tier,
iat: new Date(payload.iat * 1000),
terminateAt: new Date(payload.terminateAt)
terminateAt: new Date(payload.terminateAt),
quantity: payload.quantity,
quantity_2: payload.quantity_2
});
if (payload.type === "host") {
@@ -292,6 +307,8 @@ LQIDAQAB
cached.tier = payload.tier;
cached.iat = new Date(payload.iat * 1000);
cached.terminateAt = new Date(payload.terminateAt);
cached.quantity = payload.quantity;
cached.quantity_2 = payload.quantity_2;
// Encrypt the updated token before storing
const encryptedKey = encrypt(
@@ -317,7 +334,7 @@ LQIDAQAB
}
}
// Compute host status
// Compute host status: quantity = users, quantity_2 = sites
for (const key of keys) {
const cached = newCache.get(key.licenseKey)!;
@@ -329,6 +346,28 @@ LQIDAQAB
if (!cached.valid) {
continue;
}
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
if (
cached.quantity_2 !== undefined &&
cached.quantity_2 >= 0
) {
status.maxSites =
(status.maxSites ?? 0) + cached.quantity_2;
}
if (cached.quantity !== undefined && cached.quantity >= 0) {
status.maxUsers = (status.maxUsers ?? 0) + cached.quantity;
}
}
// Invalidate license if over user or site limits
if (
(status.maxSites !== undefined &&
(status.usedSites ?? 0) > status.maxSites) ||
(status.maxUsers !== undefined &&
(status.usedUsers ?? 0) > status.maxUsers)
) {
status.isLicenseValid = false;
}
// Invalidate old cache and set new cache
@@ -502,7 +541,7 @@ LQIDAQAB
// Calculate exponential backoff delay
const retryDelay = Math.floor(
initialRetryDelay *
Math.pow(exponentialFactor, attempt - 1)
Math.pow(exponentialFactor, attempt - 1)
);
logger.debug(

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import {
approvals,

View File

@@ -19,7 +19,7 @@ import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import { approvals, clients, db, orgs, type Approval } from "@server/db";
import { getOrgTierData } from "@server/lib/billing";
import { getOrgTierData } from "#private/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import response from "@server/lib/response";
import { and, eq, type InferInsertModel } from "drizzle-orm";

View File

@@ -29,7 +29,7 @@ const createCheckoutSessionSchema = z.strictObject({
orgId: z.string()
});
export async function createCheckoutSession(
export async function createCheckoutSessionSAAS(
req: Request,
res: Response,
next: NextFunction
@@ -87,7 +87,7 @@ export async function createCheckoutSession(
data: session.url,
success: true,
error: false,
message: "Organization created successfully",
message: "Checkout session created successfully",
status: HttpCode.CREATED
});
} catch (error) {

View File

@@ -37,18 +37,7 @@ const getOrgSchema = z.strictObject({
orgId: z.string()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/billing/subscription",
description: "Get an organization",
tags: [OpenAPITags.Org],
request: {
params: getOrgSchema
},
responses: {}
});
export async function getOrgSubscription(
export async function getOrgSubscriptions(
req: Request,
res: Response,
next: NextFunction
@@ -66,12 +55,9 @@ export async function getOrgSubscription(
const { orgId } = parsedParams.data;
let subscriptionData = null;
let itemsData: SubscriptionItem[] = [];
let subscriptions = null;
try {
const { subscription, items } = await getOrgSubscriptionData(orgId);
subscriptionData = subscription;
itemsData = items;
subscriptions = await getOrgSubscriptionsData(orgId);
} catch (err) {
if ((err as Error).message === "Not found") {
return next(
@@ -86,8 +72,7 @@ export async function getOrgSubscription(
return response<GetOrgSubscriptionResponse>(res, {
data: {
subscription: subscriptionData,
items: itemsData
subscriptions
},
success: true,
error: false,
@@ -102,9 +87,9 @@ export async function getOrgSubscription(
}
}
export async function getOrgSubscriptionData(
export async function getOrgSubscriptionsData(
orgId: string
): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> {
): Promise<Array<{ subscription: Subscription; items: SubscriptionItem[] }>> {
const org = await db
.select()
.from(orgs)
@@ -122,21 +107,21 @@ export async function getOrgSubscriptionData(
.where(eq(customers.orgId, orgId))
.limit(1);
let subscription = null;
let items: SubscriptionItem[] = [];
const subscriptionsWithItems: Array<{
subscription: Subscription;
items: SubscriptionItem[];
}> = [];
if (customer.length > 0) {
// Get subscription for customer
// Get all subscriptions for customer
const subs = await db
.select()
.from(subscriptions)
.where(eq(subscriptions.customerId, customer[0].customerId))
.limit(1);
.where(eq(subscriptions.customerId, customer[0].customerId));
if (subs.length > 0) {
subscription = subs[0];
// Get subscription items
items = await db
for (const subscription of subs) {
// Get subscription items for each subscription
const items = await db
.select()
.from(subscriptionItems)
.where(
@@ -145,8 +130,13 @@ export async function getOrgSubscriptionData(
subscription.subscriptionId
)
);
subscriptionsWithItems.push({
subscription,
items
});
}
}
return { subscription, items };
return subscriptionsWithItems;
}

View File

@@ -0,0 +1,35 @@
import {
getLicensePriceSet,
} from "@server/lib/billing/licenses";
import {
getTierPriceSet,
} from "@server/lib/billing/tiers";
import Stripe from "stripe";
export function getSubType(fullSubscription: Stripe.Response<Stripe.Subscription>): "saas" | "license" {
// Determine subscription type by checking subscription items
let type: "saas" | "license" = "saas";
if (Array.isArray(fullSubscription.items?.data)) {
for (const item of fullSubscription.items.data) {
const priceId = item.price.id;
// Check if price ID matches any license price
const licensePrices = Object.values(getLicensePriceSet());
if (licensePrices.includes(priceId)) {
type = "license";
break;
}
// Check if price ID matches any tier price (saas)
const tierPrices = Object.values(getTierPriceSet());
if (tierPrices.includes(priceId)) {
type = "saas";
break;
}
}
}
return type;
}

View File

@@ -25,6 +25,12 @@ import logger from "@server/logger";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
import { sendEmail } from "@server/emails";
import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated";
import config from "@server/lib/config";
export async function handleSubscriptionCreated(
subscription: Stripe.Subscription
@@ -123,24 +129,142 @@ export async function handleSubscriptionCreated(
return;
}
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
const type = getSubType(fullSubscription);
if (type === "saas") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Subscribed);
if (email) {
moveEmailToAudience(email, AudienceIds.Subscribed);
}
}
} else if (type === "license") {
logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.`
);
// Retrieve the client_reference_id from the checkout session
let licenseId: string | null = null;
try {
const sessions = await stripe!.checkout.sessions.list({
subscription: subscription.id,
limit: 1
});
if (sessions.data.length > 0) {
licenseId = sessions.data[0].client_reference_id || null;
}
if (!licenseId) {
logger.error(
`No client_reference_id found for subscription ${subscription.id}`
);
return;
}
logger.debug(
`Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}`
);
// Determine users and sites based on license type
const priceSet = getLicensePriceSet();
const subscriptionPriceId =
fullSubscription.items.data[0]?.price.id;
let numUsers: number;
let numSites: number;
if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) {
numUsers = 25;
numSites = 25;
} else if (
subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE]
) {
numUsers = 50;
numSites = 50;
} else {
logger.error(
`Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}`
);
return;
}
logger.debug(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
);
const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseId),
paidFor: true,
users: numUsers,
sites: numSites
})
}
);
const data = await response.json();
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (customer.email) {
logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
);
await sendEmail(
EnterpriseEditionKeyGenerated({
keyValue: data.data.licenseKey,
personalUseOnly: false,
users: numUsers,
sites: numSites,
modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing`
}),
{
to: customer.email,
from: config.getNoReplyEmail(),
subject:
"Your Enterprise Edition license key is ready"
}
);
} else {
logger.error(
`No email found for customer ${customer.customerId} to send license key.`
);
}
return data;
} catch (error) {
console.error("Error creating new license:", error);
throw error;
}
}
} catch (error) {

View File

@@ -24,11 +24,22 @@ import { eq, and } from "drizzle-orm";
import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
import { getSubType } from "./getSubType";
import stripe from "#private/lib/stripe";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription
): Promise<void> {
try {
// Fetch the subscription from Stripe with expanded price.tiers
const fullSubscription = await stripe!.subscriptions.retrieve(
subscription.id,
{
expand: ["items.data.price.tiers"]
}
);
const [existingSubscription] = await db
.select()
.from(subscriptions)
@@ -64,24 +75,62 @@ export async function handleSubscriptionDeleted(
return;
}
await handleSubscriptionLifesycle(customer.orgId, subscription.status);
const type = getSubType(fullSubscription);
if (type === "saas") {
logger.debug(
`Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
const [orgUserRes] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.orgId, customer.orgId),
eq(userOrgs.isOwner, true)
)
)
)
.innerJoin(users, eq(userOrgs.userId, users.userId));
.innerJoin(users, eq(userOrgs.userId, users.userId));
if (orgUserRes) {
const email = orgUserRes.user.email;
if (orgUserRes) {
const email = orgUserRes.user.email;
if (email) {
moveEmailToAudience(email, AudienceIds.Churned);
if (email) {
moveEmailToAudience(email, AudienceIds.Churned);
}
}
} else if (type === "license") {
logger.debug(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
);
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig().server
.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId,
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
}
} catch (error) {

View File

@@ -26,6 +26,8 @@ import logger from "@server/logger";
import { getFeatureIdByMetricId } from "@server/lib/billing/features";
import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType } from "./getSubType";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
@@ -56,7 +58,7 @@ export async function handleSubscriptionUpdated(
}
// get the customer
const [existingCustomer] = await db
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.customerId, subscription.customer as string))
@@ -74,11 +76,6 @@ export async function handleSubscriptionUpdated(
})
.where(eq(subscriptions.subscriptionId, subscription.id));
await handleSubscriptionLifesycle(
existingCustomer.orgId,
subscription.status
);
// Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({
@@ -141,20 +138,20 @@ export async function handleSubscriptionUpdated(
// This item has cycled
const meterId = item.plan.meter;
if (!meterId) {
logger.warn(
logger.debug(
`No meterId found for subscription item ${item.id}. Skipping usage reset.`
);
continue;
}
const featureId = getFeatureIdByMetricId(meterId);
if (!featureId) {
logger.warn(
logger.debug(
`No featureId found for meterId ${meterId}. Skipping usage reset.`
);
continue;
}
const orgId = existingCustomer.orgId;
const orgId = customer.orgId;
if (!orgId) {
logger.warn(
@@ -236,6 +233,45 @@ export async function handleSubscriptionUpdated(
}
}
// --- end usage update ---
const type = getSubType(fullSubscription);
if (type === "saas") {
logger.debug(
`Handling SAAS subscription lifecycle for org ${customer.orgId}`
);
// we only need to handle the limit lifecycle for saas subscriptions not for the licenses
await handleSubscriptionLifesycle(
customer.orgId,
subscription.status
);
} else {
if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") {
try {
// WARNING:
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
orgId: customer.orgId
})
}
);
} catch (error) {
logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error
);
}
}
}
}
} catch (error) {
logger.error(

View File

@@ -11,8 +11,8 @@
* This file is not licensed under the AGPLv3.
*/
export * from "./createCheckoutSession";
export * from "./createCheckoutSessionSAAS";
export * from "./createPortalSession";
export * from "./getOrgSubscription";
export * from "./getOrgSubscriptions";
export * from "./getOrgUsage";
export * from "./internalGetOrgTier";

View File

@@ -159,11 +159,11 @@ if (build === "saas") {
);
authenticated.post(
"/org/:orgId/billing/create-checkout-session",
"/org/:orgId/billing/create-checkout-session-saas",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
billing.createCheckoutSession
billing.createCheckoutSessionSAAS
);
authenticated.post(
@@ -175,10 +175,10 @@ if (build === "saas") {
);
authenticated.get(
"/org/:orgId/billing/subscription",
"/org/:orgId/billing/subscriptions",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscription
billing.getOrgSubscriptions
);
authenticated.get(
@@ -200,6 +200,14 @@ if (build === "saas") {
generateLicense.generateNewLicense
);
authenticated.put(
"/org/:orgId/license/enterprise",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
generateLicense.generateNewEnterpriseLicense
);
authenticated.post(
"/send-support-request",
rateLimit({

View File

@@ -0,0 +1,149 @@
/*
* 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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config";
import { createNewLicense } from "./generateNewLicense";
import config from "@server/lib/config";
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
import stripe from "#private/lib/stripe";
import { customers, db } from "@server/db";
import { fromError } from "zod-validation-error";
import z from "zod";
import { eq } from "drizzle-orm";
import { log } from "winston";
const generateNewEnterpriseLicenseParamsSchema = z.strictObject({
orgId: z.string()
});
export async function generateNewEnterpriseLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId } = parsedParams.data;
if (!orgId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Organization ID is required"
)
);
}
logger.debug(`Generating new license for orgId: ${orgId}`);
const licenseData = req.body;
if (licenseData.tier != "big_license" && licenseData.tier != "small_license") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid tier specified. Must be either 'big_license' or 'small_license'."
)
);
}
const apiResponse = await createNewLicense(orgId, licenseData);
// Check if the API call was successful
if (!apiResponse.success || apiResponse.error) {
return next(
createHttpError(
apiResponse.status || HttpCode.BAD_REQUEST,
apiResponse.message || "Failed to create license from Fossorial API"
)
);
}
const keyId = apiResponse?.data?.licenseKey?.id;
if (!keyId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Fossorial API did not return a valid license key ID"
)
);
}
// check if we already have a customer for this org
const [customer] = await db
.select()
.from(customers)
.where(eq(customers.orgId, orgId))
.limit(1);
// If we don't have a customer, create one
if (!customer) {
// error
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No customer found for this organization"
)
);
}
const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE;
const tierPrice = getLicensePriceSet()[tier]
const session = await stripe!.checkout.sessions.create({
client_reference_id: keyId.toString(),
billing_address_collection: "required",
line_items: [
{
price: tierPrice, // Use the standard tier
quantity: 1
},
], // Start with the standard feature set that matches the free limits
customer: customer.customerId,
mode: "subscription",
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
});
return sendResponse<string>(res, {
data: session.url,
success: true,
error: false,
message: "License and checkout session created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while generating new license."
)
);
}
}

View File

@@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response";
import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
export interface CreateNewLicenseResponse {
data: Data
success: boolean
error: boolean
message: string
status: number
}
export interface Data {
licenseKey: LicenseKey
}
export interface LicenseKey {
id: number
instanceName: any
instanceId: string
licenseKey: string
tier: string
type: string
quantity: number
quantity_2: number
isValid: boolean
updatedAt: string
createdAt: string
expiresAt: string
paidFor: boolean
orgId: string
metadata: string
}
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`,
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
{
method: "PUT",
headers: {
@@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise<any> {
}
);
const data = await response.json();
const data: CreateNewLicenseResponse = await response.json();
logger.debug("Fossorial API response:", { data });
return data;
} catch (error) {
console.error("Error creating new license:", error);

View File

@@ -13,3 +13,4 @@
export * from "./listGeneratedLicenses";
export * from "./generateNewLicense";
export * from "./generateNewEnterpriseLicense";

View File

@@ -25,7 +25,7 @@ import {
async function fetchLicenseKeys(orgId: string): Promise<any> {
try {
const response = await fetch(
`https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`,
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`,
{
method: "GET",
headers: {

View File

@@ -186,7 +186,7 @@ export type ResourceWithAuth = {
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org
org: Org;
};
export type UserSessionWithUser = {
@@ -270,7 +270,6 @@ hybridRouter.get(
}
);
let encryptionKeyPath = "";
let encryptionKeyHex = "";
let encryptionKey: Buffer;
function loadEncryptData() {
@@ -278,16 +277,8 @@ function loadEncryptData() {
return; // already loaded
}
encryptionKeyPath =
privateConfig.getRawPrivateConfig().server.encryption_key_path;
if (!fs.existsSync(encryptionKeyPath)) {
throw new Error(
"Encryption key file not found. Please generate one first."
);
}
encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim();
encryptionKeyHex =
privateConfig.getRawPrivateConfig().server.encryption_key;
encryptionKey = Buffer.from(encryptionKeyHex, "hex");
}

View File

@@ -37,27 +37,55 @@ const paramsSchema = z.strictObject({
const bodySchema = z.strictObject({
logoUrl: z
.union([
z.string().length(0),
z.url().refine(
async (url) => {
z.literal(""),
z
.url("Must be a valid URL")
.superRefine(async (url, ctx) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(
response.headers.get("content-type") ?? ""
).startsWith("image/")
);
const response = await fetch(url, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
const contentType =
response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
return false;
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (error instanceof TypeError && error.message.includes("fetch")) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
},
{
error: "Invalid logo URL, must be a valid image URL"
}
)
})
])
.optional(),
.transform((val) => (val === "" ? null : val))
.nullish(),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
resourceTitle: z.string(),
@@ -78,7 +106,7 @@ export async function upsertLoginPageBranding(
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
const parsedBody = await bodySchema.safeParseAsync(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
@@ -117,9 +145,8 @@ export async function upsertLoginPageBranding(
typeof loginPageBranding
>;
if ((updateData.logoUrl ?? "").trim().length === 0) {
updateData.logoUrl = undefined;
}
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
if (
build !== "saas" &&

View File

@@ -1,8 +1,7 @@
import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db";
export type GetOrgSubscriptionResponse = {
subscription: Subscription | null;
items: SubscriptionItem[];
subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>;
};
export type GetOrgUsageResponse = {

View File

@@ -26,7 +26,8 @@ const applyBlueprintSchema = z
message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}`
});
}
})
}),
source: z.enum(["API", "UI", "CLI"]).optional()
})
.strict();
@@ -84,7 +85,7 @@ export async function applyYAMLBlueprint(
);
}
const { blueprint: contents, name } = parsedBody.data;
const { blueprint: contents, name, source = "UI" } = parsedBody.data;
logger.debug(`Received blueprint:`, contents);
@@ -107,7 +108,7 @@ export async function applyYAMLBlueprint(
blueprint = await applyBlueprint({
orgId,
name,
source: "UI",
source,
configData: parsedConfig
});
} catch (err) {

View File

@@ -1,6 +1,6 @@
import type { Blueprint } from "@server/db";
export type BlueprintSource = "API" | "UI" | "NEWT";
export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI";
export type BlueprintData = Omit<Blueprint, "source"> & {
source: BlueprintSource;

View File

@@ -9,9 +9,6 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -77,9 +74,6 @@ export async function archiveClient(
.update(clients)
.set({ archived: true })
.where(eq(clients.clientId, clientId));
// Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx);
});
return response(res, {

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms } from "@server/db";
import { db, olms, users } from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
@@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const getClientSchema = z.strictObject({
clientId: z
@@ -35,6 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.leftJoin(users, eq(clients.userId, users.userId))
.limit(1);
return res;
} else if (niceId && orgId) {
@@ -47,6 +49,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.leftJoin(users, eq(clients.userId, users.userId))
.limit(1);
return res;
}
@@ -58,7 +61,7 @@ type PostureData = {
firewallEnabled?: boolean | null;
autoUpdatesEnabled?: boolean | null;
tpmAvailable?: boolean | null;
windowsDefenderEnabled?: boolean | null;
windowsAntivirusEnabled?: boolean | null;
macosSipEnabled?: boolean | null;
macosGatekeeperEnabled?: boolean | null;
macosFirewallStealthMode?: boolean | null;
@@ -75,78 +78,123 @@ function getPlatformPostureData(
const normalizedPlatform = platform?.toLowerCase() || "unknown";
const posture: PostureData = {};
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Defender
// Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status
if (normalizedPlatform === "windows") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
if (
fingerprint.tpmAvailable !== null &&
fingerprint.tpmAvailable !== undefined
) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
if (fingerprint.windowsDefenderEnabled !== null && fingerprint.windowsDefenderEnabled !== undefined) {
posture.windowsDefenderEnabled = fingerprint.windowsDefenderEnabled;
if (
fingerprint.windowsAntivirusEnabled !== null &&
fingerprint.windowsAntivirusEnabled !== undefined
) {
posture.windowsAntivirusEnabled =
fingerprint.windowsAntivirusEnabled;
}
}
// macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode
else if (normalizedPlatform === "macos") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
if (
fingerprint.biometricsEnabled !== null &&
fingerprint.biometricsEnabled !== undefined
) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined) {
if (
fingerprint.macosSipEnabled !== null &&
fingerprint.macosSipEnabled !== undefined
) {
posture.macosSipEnabled = fingerprint.macosSipEnabled;
}
if (fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined) {
if (
fingerprint.macosGatekeeperEnabled !== null &&
fingerprint.macosGatekeeperEnabled !== undefined
) {
posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled;
}
if (fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined) {
posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode;
if (
fingerprint.macosFirewallStealthMode !== null &&
fingerprint.macosFirewallStealthMode !== undefined
) {
posture.macosFirewallStealthMode =
fingerprint.macosFirewallStealthMode;
}
if (fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined) {
if (
fingerprint.autoUpdatesEnabled !== null &&
fingerprint.autoUpdatesEnabled !== undefined
) {
posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled;
}
}
// Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability
else if (normalizedPlatform === "linux") {
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
if (fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined) {
if (
fingerprint.firewallEnabled !== null &&
fingerprint.firewallEnabled !== undefined
) {
posture.firewallEnabled = fingerprint.firewallEnabled;
}
if (fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined) {
if (
fingerprint.linuxAppArmorEnabled !== null &&
fingerprint.linuxAppArmorEnabled !== undefined
) {
posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled;
}
if (fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined) {
if (
fingerprint.linuxSELinuxEnabled !== null &&
fingerprint.linuxSELinuxEnabled !== undefined
) {
posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled;
}
if (fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined) {
if (
fingerprint.tpmAvailable !== null &&
fingerprint.tpmAvailable !== undefined
) {
posture.tpmAvailable = fingerprint.tpmAvailable;
}
}
// iOS: Biometric configuration
else if (normalizedPlatform === "ios") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
// none supported yet
}
// Android: Screen lock, Biometric configuration, Hard drive encryption
else if (normalizedPlatform === "android") {
if (fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined) {
posture.biometricsEnabled = fingerprint.biometricsEnabled;
}
if (fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined) {
if (
fingerprint.diskEncrypted !== null &&
fingerprint.diskEncrypted !== undefined
) {
posture.diskEncrypted = fingerprint.diskEncrypted;
}
}
@@ -161,6 +209,9 @@ export type GetClientResponse = NonNullable<
olmId: string | null;
agent: string | null;
olmVersion: string | null;
userEmail: string | null;
userName: string | null;
userUsername: string | null;
fingerprint: {
username: string | null;
hostname: string | null;
@@ -243,23 +294,27 @@ export async function getClient(
// Build fingerprint data if available
const fingerprintData = client.currentFingerprint
? {
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
: null;
// Build posture data if available (platform-specific)
// Only return posture data if org is licensed/subscribed
let postureData: PostureData | null = null;
if (build !== "oss") {
const isOrgLicensed = await isLicensedOrSubscribed(
client.clients.orgId
);
if (isOrgLicensed) {
postureData = getPlatformPostureData(
client.currentFingerprint?.platform || null,
client.currentFingerprint
@@ -272,6 +327,9 @@ export async function getClient(
olmId: client.olms ? client.olms.olmId : null,
agent: client.olms?.agent || null,
olmVersion: client.olms?.version || null,
userEmail: client.user?.email ?? null,
userName: client.user?.name ?? null,
userUsername: client.user?.username ?? null,
fingerprint: fingerprintData,
posture: postureData
};

View File

@@ -6,6 +6,8 @@ export type GeneratedLicenseKey = {
createdAt: string;
tier: string;
type: string;
users: number;
sites: number;
};
export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[];
@@ -19,6 +21,7 @@ export type NewLicenseKey = {
tier: string;
type: string;
quantity: number;
quantity_2: number;
isValid: boolean;
updatedAt: string;
createdAt: string;

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
import { olms, clients } from "@server/db";
import { olms } from "@server/db";
import { eq } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -8,9 +8,6 @@ import response from "@server/lib/response";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z
.object({
@@ -37,26 +34,7 @@ export async function archiveUserOlm(
const { olmId } = parsedParams.data;
// Archive the OLM and disconnect associated clients in a transaction
await db.transaction(async (trx) => {
// Find all clients associated with this OLM
const associatedClients = await trx
.select()
.from(clients)
.where(eq(clients.olmId, olmId));
// Disconnect clients from the OLM (set olmId to null)
for (const client of associatedClients) {
await trx
.update(clients)
.set({ olmId: null })
.where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
}
// Archive the OLM (set archived to true)
await trx
.update(olms)
.set({ archived: true })

View File

@@ -22,7 +22,7 @@ function fingerprintSnapshotHash(fingerprint: any, postures: any): string {
autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false,
tpmAvailable: postures.tpmAvailable ?? false,
windowsDefenderEnabled: postures.windowsDefenderEnabled ?? false,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false,
macosSipEnabled: postures.macosSipEnabled ?? false,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false,
@@ -87,7 +87,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -117,7 +117,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -162,7 +162,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,
@@ -197,7 +197,7 @@ export async function handleFingerprintInsertion(
autoUpdatesEnabled: postures.autoUpdatesEnabled,
tpmAvailable: postures.tpmAvailable,
windowsDefenderEnabled: postures.windowsDefenderEnabled,
windowsAntivirusEnabled: postures.windowsAntivirusEnabled,
macosSipEnabled: postures.macosSipEnabled,
macosGatekeeperEnabled: postures.macosGatekeeperEnabled,

View File

@@ -13,6 +13,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils";
@@ -46,6 +47,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
logger.debug("Handling fingerprint insertion for olm register...", {
olmId: olm.olmId,
fingerprint,
postures
});
await handleFingerprintInsertion(olm, fingerprint, postures);
if (
@@ -91,6 +98,21 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
const deviceModel = fingerprint?.deviceModel ?? null;
const computedName = getUserDeviceName(deviceModel, client.name);
if (computedName && computedName !== client.name) {
await db
.update(clients)
.set({ name: computedName })
.where(eq(clients.clientId, client.clientId));
}
if (computedName && computedName !== olm.name) {
await db
.update(olms)
.set({ name: computedName })
.where(eq(olms.olmId, olm.olmId));
}
const [org] = await db
.select()
.from(orgs)
@@ -143,7 +165,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
return;
}
if (!policyCheck.policies?.passwordAge?.compliant === false) {
if (policyCheck.policies?.passwordAge?.compliant === false) {
logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
);
@@ -153,7 +175,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
);
return;
} else if (
!policyCheck.policies?.maxSessionLength?.compliant === false
policyCheck.policies?.maxSessionLength?.compliant === false
) {
logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`

View File

@@ -13,7 +13,7 @@ import { build } from "@server/build";
import { getOrgTierData } from "#dynamic/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { cache } from "@server/lib/cache";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const updateOrgParamsSchema = z.strictObject({
orgId: z.string()
@@ -89,7 +89,7 @@ export async function updateOrg(
const { orgId } = parsedParams.data;
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build == "enterprise" && !isLicensed) {
if (!isLicensed) {
parsedBody.data.requireTwoFactor = undefined;
parsedBody.data.maxSessionLengthHours = undefined;
parsedBody.data.passwordExpiryDays = undefined;

View File

@@ -23,7 +23,7 @@ import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const updateResourceParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -342,11 +342,7 @@ async function updateHttpResource(
}
const isLicensed = await isLicensedOrSubscribed(resource.orgId);
if (build == "enterprise" && !isLicensed) {
logger.warn(
"Server is not licensed! Clearing set maintenance screen values"
);
// null the maintenance mode fields if not licensed
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;
updateData.maintenanceTitle = undefined;

View File

@@ -11,7 +11,7 @@ import { ActionsEnum } from "@server/auth/actions";
import { eq, and } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
const createRoleParamsSchema = z.strictObject({
orgId: z.string()
@@ -101,7 +101,7 @@ export async function createRole(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build === "oss" || !isLicensed) {
if (!isLicensed) {
roleData.requireDeviceApproval = undefined;
}

View File

@@ -8,8 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "@server/lib/isLicencedOrSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { OpenAPITags, registry } from "@server/openApi";
const updateRoleParamsSchema = z.strictObject({
@@ -112,7 +111,7 @@ export async function updateRole(
}
const isLicensed = await isLicensedOrSubscribed(orgId);
if (build === "oss" || !isLicensed) {
if (!isLicensed) {
updateData.requireDeviceApproval = undefined;
}

View File

@@ -17,7 +17,6 @@ import { hashPassword } from "@server/auth/password";
import { isValidIP } from "@server/lib/validators";
import { isIpInCidr } from "@server/lib/ip";
import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes";
import { build } from "@server/build";
const createSiteParamsSchema = z.strictObject({
orgId: z.string()
@@ -259,7 +258,19 @@ export async function createSite(
let newSite: Site;
await db.transaction(async (trx) => {
if (type == "wireguard" || type == "newt") {
if (type == "newt") {
[newSite] = await trx
.insert(sites)
.values({
orgId,
name,
niceId,
address: updatedAddress || null,
type,
dockerSocketEnabled: true
})
.returning();
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
@@ -311,11 +322,9 @@ export async function createSite(
exitNodeId,
name,
niceId,
address: updatedAddress || null,
subnet,
type,
dockerSocketEnabled: type == "newt",
...(pubKey && type == "wireguard" && { pubKey })
pubKey: pubKey || null
})
.returning();
} else if (type == "local") {

View File

@@ -64,16 +64,20 @@ export async function ensureSetupToken() {
);
}
if (existingToken?.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
if (existingToken) {
// Token exists in DB - update it if different
if (existingToken.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
}
} else {
// No existing token - insert new one
const tokenId = generateId(15);
await db.insert(setupTokens).values({

View File

@@ -49,7 +49,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL,
@@ -75,7 +75,7 @@ export default async function migration() {
"firewallEnabled" boolean DEFAULT false NOT NULL,
"autoUpdatesEnabled" boolean DEFAULT false NOT NULL,
"tpmAvailable" boolean DEFAULT false NOT NULL,
"windowsDefenderEnabled" boolean DEFAULT false NOT NULL,
"windowsAntivirusEnabled" boolean DEFAULT false NOT NULL,
"macosSipEnabled" boolean DEFAULT false NOT NULL,
"macosGatekeeperEnabled" boolean DEFAULT false NOT NULL,
"macosFirewallStealthMode" boolean DEFAULT false NOT NULL,

View File

@@ -53,7 +53,7 @@ CREATE TABLE 'currentFingerprint' (
'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,
@@ -83,7 +83,7 @@ CREATE TABLE 'fingerprintSnapshots' (
'firewallEnabled' integer DEFAULT false NOT NULL,
'autoUpdatesEnabled' integer DEFAULT false NOT NULL,
'tpmAvailable' integer DEFAULT false NOT NULL,
'windowsDefenderEnabled' integer DEFAULT false NOT NULL,
'windowsAntivirusEnabled' integer DEFAULT false NOT NULL,
'macosSipEnabled' integer DEFAULT false NOT NULL,
'macosGatekeeperEnabled' integer DEFAULT false NOT NULL,
'macosFirewallStealthMode' integer DEFAULT false NOT NULL,

View File

@@ -18,6 +18,7 @@ import { build } from "@server/build";
import OrgPolicyResult from "@app/components/OrgPolicyResult";
import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout";
import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect";
export default async function OrgLayout(props: {
children: React.ReactNode;
@@ -70,6 +71,7 @@ export default async function OrgLayout(props: {
} catch (e) {}
return (
<UserProvider user={user}>
<ApplyInternalRedirect orgId={orgId} />
<Layout orgId={orgId} navItems={[]} orgs={orgs}>
<OrgPolicyResult
orgId={orgId}
@@ -86,7 +88,7 @@ export default async function OrgLayout(props: {
try {
const getSubscription = cache(() =>
internal.get<AxiosResponse<GetOrgSubscriptionResponse>>(
`/org/${orgId}/billing/subscription`,
`/org/${orgId}/billing/subscriptions`,
cookie
)
);
@@ -104,6 +106,7 @@ export default async function OrgLayout(props: {
env={env.app.environment}
sandbox_mode={env.app.sandbox_mode}
>
<ApplyInternalRedirect orgId={orgId} />
{props.children}
<SetLastOrgCookie orgId={orgId} />
</SubscriptionStatusProvider>

View File

@@ -19,17 +19,6 @@ export interface ApprovalFeedPageProps {
export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) {
const params = await props.params;
let approvals: ApprovalItem[] = [];
const res = await internal
.get<
AxiosResponse<{ approvals: ApprovalItem[] }>
>(`/org/${params.orgId}/approvals`, await authCookieHeader())
.catch((e) => {});
if (res && res.status === 200) {
approvals = res.data.data.approvals;
}
let org: GetOrgResponse | null = null;
const orgRes = await getCachedOrg(params.orgId);

View File

@@ -43,15 +43,18 @@ import Link from "next/link";
export default function GeneralPage() {
const { org } = useOrgContext();
const api = createApiClient(useEnvContext());
const envContext = useEnvContext();
const api = createApiClient(envContext);
const t = useTranslations();
// Subscription state
const [subscription, setSubscription] =
useState<GetOrgSubscriptionResponse["subscription"]>(null);
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
// Subscription state - now handling multiple subscriptions
const [allSubscriptions, setAllSubscriptions] = useState<
GetOrgSubscriptionResponse["subscriptions"]
>([]);
const [tierSubscription, setTierSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [licenseSubscription, setLicenseSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available)
@@ -68,12 +71,41 @@ export default function GeneralPage() {
try {
const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscription`);
const { subscription, items } = res.data.data;
setSubscription(subscription);
setSubscriptionItems(items);
>(`/org/${org.org.orgId}/billing/subscriptions`);
const { subscriptions } = res.data.data;
setAllSubscriptions(subscriptions);
// Import tier and license price sets
const { getTierPriceSet } = await import("@server/lib/billing/tiers");
const { getLicensePriceSet } = await import("@server/lib/billing/licenses");
const tierPriceSet = getTierPriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
const licensePriceSet = getLicensePriceSet(
envContext.env.app.environment,
envContext.env.app.sandbox_mode
);
// Find tier subscription (subscription with items matching tier prices)
const tierSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(tierPriceSet).includes(item.priceId)
)
);
setTierSubscription(tierSub || null);
// Find license subscription (subscription with items matching license prices)
const licenseSub = subscriptions.find(({ items }) =>
items.some((item) =>
item.priceId && Object.values(licensePriceSet).includes(item.priceId)
)
);
setLicenseSubscription(licenseSub || null);
setHasSubscription(
!!subscription && subscription.status === "active"
!!tierSub?.subscription && tierSub.subscription.status === "active"
);
} catch (error) {
toast({
@@ -121,7 +153,7 @@ export default function GeneralPage() {
setIsLoading(true);
try {
const response = await api.post<AxiosResponse<string>>(
`/org/${org.org.orgId}/billing/create-checkout-session`,
`/org/${org.org.orgId}/billing/create-checkout-session-saas`,
{}
);
console.log("Checkout session response:", response.data);
@@ -302,6 +334,10 @@ export default function GeneralPage() {
return { usage: usage ?? 0, item, limit };
}
// Get tier subscription items
const tierSubscriptionItems = tierSubscription?.items || [];
const tierSubscriptionData = tierSubscription?.subscription || null;
// Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false;
@@ -388,15 +424,15 @@ export default function GeneralPage() {
<div className="flex items-center justify-between mb-6">
<Badge
variant={
subscription?.status === "active" ? "green" : "outline"
tierSubscriptionData?.status === "active" ? "green" : "outline"
}
>
{subscription?.status === "active" && (
{tierSubscriptionData?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{subscription
? subscription.status.charAt(0).toUpperCase() +
subscription.status.slice(1)
{tierSubscriptionData
? tierSubscriptionData.status.charAt(0).toUpperCase() +
tierSubscriptionData.status.slice(1)
: t("billingFreeTier")}
</Badge>
<Link
@@ -413,7 +449,7 @@ export default function GeneralPage() {
{usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -441,7 +477,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -530,7 +566,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -614,7 +650,7 @@ export default function GeneralPage() {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -636,7 +672,7 @@ export default function GeneralPage() {
);
})}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{subscriptionItems
{tierSubscriptionItems
.filter(
(item) =>
item.unitAmount &&
@@ -672,7 +708,7 @@ export default function GeneralPage() {
const { usage, item } =
getUsageItemAndLimit(
usageData,
subscriptionItems,
tierSubscriptionItems,
limitsData,
type.id
);
@@ -687,7 +723,7 @@ export default function GeneralPage() {
return sum + cost;
}, 0) +
// Add recurring charges
subscriptionItems
tierSubscriptionItems
.filter(
(item) =>
item.unitAmount &&
@@ -749,6 +785,56 @@ export default function GeneralPage() {
</SettingsSectionBody>
</SettingsSection>
)}
{/* License Keys Section */}
{licenseSubscription && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("billingLicenseKeys") || "License Keys"}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("billingLicenseKeysDescription") || "Manage your license key subscriptions"}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-2">
<CreditCard className="h-5 w-5 text-primary" />
<span className="font-semibold">
{t("billingLicenseSubscription") || "License Subscription"}
</span>
</div>
<Badge
variant={
licenseSubscription.subscription?.status === "active"
? "green"
: "outline"
}
>
{licenseSubscription.subscription?.status === "active" && (
<CheckCircle className="h-3 w-3 mr-1" />
)}
{licenseSubscription.subscription?.status
? licenseSubscription.subscription.status
.charAt(0)
.toUpperCase() +
licenseSubscription.subscription.status.slice(1)
: t("billingInactive") || "Inactive"}
</Badge>
</div>
<SettingsSectionFooter>
<Button
variant="secondary"
onClick={() => handleModifySubscription()}
disabled={isLoading}
>
{t("billingModifyLicenses") || "Modify License Subscription"}
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -656,17 +656,17 @@ export default function GeneralPage() {
</InfoSection>
)}
{client.posture.windowsDefenderEnabled !== null &&
client.posture.windowsDefenderEnabled !== undefined && (
{client.posture.windowsAntivirusEnabled !== null &&
client.posture.windowsAntivirusEnabled !== undefined && (
<InfoSection>
<InfoSectionTitle>
{t("windowsDefenderEnabled")}
{t("windowsAntivirusEnabled")}
</InfoSectionTitle>
<InfoSectionContent>
{isPaidUser
? formatPostureValue(
client.posture
.windowsDefenderEnabled
.windowsAntivirusEnabled
)
: "-"}
</InfoSectionContent>

View File

@@ -11,7 +11,6 @@ import {
SelectValue
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { ContainersSelector } from "@app/components/ContainersSelector";
import { HeadersInput } from "@app/components/HeadersInput";
import {
PathMatchDisplay,
@@ -19,6 +18,7 @@ import {
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import {
SettingsContainer,
SettingsSection,
@@ -30,15 +30,6 @@ import {
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Badge } from "@app/components/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Form,
FormControl,
@@ -48,11 +39,6 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Table,
TableBody,
@@ -73,12 +59,9 @@ import { useResourceContext } from "@app/hooks/useResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
import { cn } from "@app/lib/cn";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { orgQueries, resourceQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod";
import { CaretSortIcon } from "@radix-ui/react-icons";
import { tlsNameSchema } from "@server/lib/schemas";
import { type GetResourceResponse } from "@server/routers/resource";
import type { ListSitesResponse } from "@server/routers/site";
@@ -98,7 +81,6 @@ import {
import { AxiosResponse } from "axios";
import {
AlertTriangle,
CheckIcon,
CircleCheck,
CircleX,
Info,
@@ -107,7 +89,7 @@ import {
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { use, useActionState, useEffect, useState } from "react";
import { use, useActionState, useCallback, useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -202,7 +184,7 @@ function ProxyResourceTargetsForm({
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const refreshContainersForSite = useCallback(async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
@@ -214,9 +196,9 @@ function ProxyResourceTargetsForm({
}
return newMap;
});
};
}, [api]);
const getDockerStateForSite = (siteId: number): DockerState => {
const getDockerStateForSite = useCallback((siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
@@ -224,7 +206,7 @@ function ProxyResourceTargetsForm({
containers: []
}
);
};
}, [dockerStates]);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") {
@@ -234,8 +216,40 @@ function ProxyResourceTargetsForm({
return false;
});
const getColumns = (): ColumnDef<LocalTarget>[] => {
const isHttp = resource.http;
const isHttp = resource.http;
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
const targetToRemove = prevTargets.find((target) => target.targetId === targetId);
if (targetToRemove && !targetToRemove.new) {
setTargetsToRemove((prev) => [...prev, targetId]);
}
return prevTargets.filter((target) => target.targetId !== targetId);
});
}, []);
const updateTarget = useCallback((targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
}, [sites]);
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
}, []);
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
@@ -419,213 +433,15 @@ function ProxyResourceTargetsForm({
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{resource.http && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] text-sm font-normal border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{resource.http && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
<ResourceTargetAddressItem
isHttp={isHttp}
sites={sites}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite}
updateTarget={updateTarget}
/>
);
},
size: 400,
@@ -765,7 +581,7 @@ function ProxyResourceTargetsForm({
actionsColumn
];
}
};
}, [isAdvancedMode, isHttp, sites, updateTarget, getDockerStateForSite, refreshContainersForSite, openHealthCheckDialog, removeTarget, t]);
function addNewTarget() {
const isHttp = resource.http;
@@ -806,32 +622,6 @@ function ProxyResourceTargetsForm({
setTargets((prev) => [...prev, newTarget]);
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
function updateTargetHealthCheck(targetId: number, config: any) {
setTargets(
targets.map((target) =>
@@ -846,14 +636,6 @@ function ProxyResourceTargetsForm({
);
}
const openHealthCheckDialog = (target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
const columns = getColumns();
const table = useReactTable({
data: targets,
columns,

View File

@@ -1,5 +1,14 @@
"use client";
import CopyTextBox from "@app/components/CopyTextBox";
import DomainPicker from "@app/components/DomainPicker";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import {
SettingsContainer,
SettingsSection,
@@ -9,6 +18,10 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { StrategySelect } from "@app/components/StrategySelect";
import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
@@ -18,22 +31,7 @@ import {
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { ListSitesResponse } from "@server/routers/site";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db";
import { StrategySelect } from "@app/components/StrategySelect";
import {
Select,
SelectContent,
@@ -41,48 +39,7 @@ import {
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { ListDomainsResponse } from "@server/routers/domain";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { cn } from "@app/lib/cn";
import {
ArrowRight,
CircleCheck,
CircleX,
Info,
MoveRight,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
import { build } from "@server/build";
import { ContainersSelector } from "@app/components/ContainersSelector";
import {
ColumnDef,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
getCoreRowModel,
useReactTable,
flexRender,
Row
} from "@tanstack/react-table";
import { Switch } from "@app/components/ui/switch";
import {
Table,
TableBody,
@@ -91,30 +48,49 @@ import {
TableHeader,
TableRow
} from "@app/components/ui/table";
import { Switch } from "@app/components/ui/switch";
import { ArrayElement } from "@server/types/ArrayElement";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { DockerManager, DockerState } from "@app/lib/docker";
import { parseHostTarget } from "@app/lib/parseHostTarget";
import { toASCII, toUnicode } from "punycode";
import { DomainRow } from "@app/components/DomainsTable";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target";
import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query";
import {
PathMatchDisplay,
PathMatchModal,
PathRewriteDisplay,
PathRewriteModal
} from "@app/components/PathMatchRenameModal";
import { Badge } from "@app/components/ui/badge";
import HealthCheckDialog from "@app/components/HealthCheckDialog";
import { SwitchInput } from "@app/components/SwitchInput";
ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
CircleCheck,
CircleX,
Info,
Plus,
Settings,
SquareArrowOutUpRight
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { toASCII } from "punycode";
import { useEffect, useMemo, useState, useCallback } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -204,10 +180,6 @@ const addTargetSchema = z
}
);
type BaseResourceFormValues = z.infer<typeof baseResourceFormSchema>;
type HttpResourceFormValues = z.infer<typeof httpResourceFormSchema>;
type TcpUdpResourceFormValues = z.infer<typeof tcpUdpResourceFormSchema>;
type ResourceType = "http" | "raw";
interface ResourceTypeOption {
@@ -217,7 +189,7 @@ interface ResourceTypeOption {
disabled?: boolean;
}
type LocalTarget = Omit<
export type LocalTarget = Omit<
ArrayElement<ListTargetsResponse["targets"]> & {
new?: boolean;
updated?: boolean;
@@ -233,18 +205,16 @@ export default function Page() {
const router = useRouter();
const t = useTranslations();
const [loadingPage, setLoadingPage] = useState(true);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [baseDomains, setBaseDomains] = useState<
{ domainId: string; baseDomain: string }[]
>([]);
const { data: sites = [], isLoading: loadingPage } = useQuery(
orgQueries.sites({ orgId: orgId as string })
);
const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>("");
// Target management state
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
@@ -405,102 +375,60 @@ export default function Page() {
setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
};
const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
const refreshContainersForSite = useCallback(
async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers();
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
setDockerStates((prev) => {
const newMap = new Map(prev);
const existingState = newMap.get(siteId);
if (existingState) {
newMap.set(siteId, { ...existingState, containers });
}
return newMap;
});
},
[api]
);
const getDockerStateForSite = useCallback(
(siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
},
[dockerStates]
);
const removeTarget = useCallback((targetId: number) => {
setTargets((prevTargets) => {
return prevTargets.filter((target) => target.targetId !== targetId);
});
};
}, []);
const getDockerStateForSite = (siteId: number): DockerState => {
return (
dockerStates.get(siteId) || {
isEnabled: false,
isAvailable: false,
containers: []
}
);
};
async function addTarget(data: z.infer<typeof addTargetSchema>) {
const site = sites.find((site) => site.siteId === data.siteId);
const isHttp = baseForm.watch("http");
const newTarget: LocalTarget = {
...data,
path: isHttp ? data.path || null : null,
pathMatchType: isHttp ? data.pathMatchType || null : null,
rewritePath: isHttp ? data.rewritePath || null : null,
rewritePathType: isHttp ? data.rewritePathType || null : null,
siteType: site?.type || null,
enabled: true,
targetId: new Date().getTime(),
new: true,
resourceId: 0, // Will be set when resource is created
priority: isHttp ? data.priority || 100 : 100, // Default priority
hcEnabled: false,
hcPath: null,
hcMethod: null,
hcInterval: null,
hcTimeout: null,
hcHeaders: null,
hcScheme: null,
hcHostname: null,
hcPort: null,
hcFollowRedirects: null,
hcHealth: "unknown",
hcStatus: null,
hcMode: null,
hcUnhealthyInterval: null,
hcTlsServerName: null
};
setTargets([...targets, newTarget]);
addTargetForm.reset({
ip: "",
method: baseForm.watch("http") ? "http" : null,
port: "" as any as number,
path: null,
pathMatchType: null,
rewritePath: null,
rewritePathType: null,
priority: isHttp ? 100 : undefined
});
}
const removeTarget = (targetId: number) => {
setTargets([
...targets.filter((target) => target.targetId !== targetId)
]);
if (!targets.find((target) => target.targetId === targetId)?.new) {
setTargetsToRemove([...targetsToRemove, targetId]);
}
};
async function updateTarget(targetId: number, data: Partial<LocalTarget>) {
const site = sites.find((site) => site.siteId === data.siteId);
setTargets(
targets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
)
);
}
const updateTarget = useCallback(
(targetId: number, data: Partial<LocalTarget>) => {
setTargets((prevTargets) => {
const site = sites.find((site) => site.siteId === data.siteId);
return prevTargets.map((target) =>
target.targetId === targetId
? {
...target,
...data,
updated: true,
siteType: site ? site.type : target.siteType
}
: target
);
});
},
[sites]
);
async function onSubmit() {
setCreateLoading(true);
@@ -638,82 +566,18 @@ export default function Page() {
}
useEffect(() => {
const load = async () => {
setLoadingPage(true);
// Initialize Docker for newt sites
for (const site of sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
const fetchSites = async () => {
const res = await api
.get<
AxiosResponse<ListSitesResponse>
>(`/org/${orgId}/sites/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("sitesErrorFetch"),
description: formatAxiosError(
e,
t("sitesErrorFetchDescription")
)
});
});
if (res?.status === 200) {
setSites(res.data.data.sites);
// Initialize Docker for newt sites
for (const site of res.data.data.sites) {
if (site.type === "newt") {
initializeDockerForSite(site.siteId);
}
}
// If there's only one site, set it as the default in the form
if (res.data.data.sites.length) {
addTargetForm.setValue(
"siteId",
res.data.data.sites[0].siteId
);
}
}
};
const fetchDomains = async () => {
const res = await api
.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains/`)
.catch((e) => {
toast({
variant: "destructive",
title: t("domainsErrorFetch"),
description: formatAxiosError(
e,
t("domainsErrorFetchDescription")
)
});
});
if (res?.status === 200) {
const rawDomains = res.data.data.domains as DomainRow[];
const domains = rawDomains.map((domain) => ({
...domain,
baseDomain: toUnicode(domain.baseDomain)
}));
setBaseDomains(domains);
// if (domains.length) {
// httpForm.setValue("domainId", domains[0].domainId);
// }
}
};
await fetchSites();
await fetchDomains();
setLoadingPage(false);
};
load();
}, []);
// If there's at least one site, set it as the default in the form
if (sites.length > 0) {
addTargetForm.setValue("siteId", sites[0].siteId);
}
}, [sites]);
function TargetHealthCheck(targetId: number, config: any) {
setTargets(
@@ -729,16 +593,15 @@ export default function Page() {
);
}
const openHealthCheckDialog = (target: LocalTarget) => {
const openHealthCheckDialog = useCallback((target: LocalTarget) => {
console.log(target);
setSelectedTargetForHealthCheck(target);
setHealthCheckDialogOpen(true);
};
}, []);
const getColumns = (): ColumnDef<LocalTarget>[] => {
const baseColumns: ColumnDef<LocalTarget>[] = [];
const isHttp = baseForm.watch("http");
const isHttp = baseForm.watch("http");
const columns = useMemo((): ColumnDef<LocalTarget>[] => {
const priorityColumn: ColumnDef<LocalTarget> = {
id: "priority",
header: () => (
@@ -875,7 +738,7 @@ export default function Page() {
trigger={
<Button
variant="outline"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-[200px]"
className="flex items-center gap-2 p-2 w-full text-left cursor-pointer max-w-50"
>
<PathMatchDisplay
value={{
@@ -899,7 +762,7 @@ export default function Page() {
trigger={
<Button
variant="outline"
className="w-full max-w-[200px]"
className="w-full max-w-50"
>
<Plus className="h-4 w-4 mr-2" />
{t("matchPath")}
@@ -918,216 +781,16 @@ export default function Page() {
const addressColumn: ColumnDef<LocalTarget> = {
accessorKey: "address",
header: () => <span className="p-3">{t("address")}</span>,
cell: ({ row }) => {
const selectedSite = sites.find(
(site) => site.siteId === row.original.siteId
);
const handleContainerSelectForTarget = (
hostname: string,
port?: number
) => {
updateTarget(row.original.targetId, {
...row.original,
ip: hostname,
...(port && { port: port })
});
};
return (
<div className="flex items-center w-full">
<div className="flex items-center w-full justify-start py-0 space-x-2 px-0 cursor-default border border-input rounded-md">
{selectedSite &&
selectedSite.type === "newt" &&
(() => {
const dockerState = getDockerStateForSite(
selectedSite.siteId
);
return (
<ContainersSelector
site={selectedSite}
containers={dockerState.containers}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
className={cn(
"w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent",
!row.original.siteId &&
"text-muted-foreground"
)}
>
<span className="truncate max-w-[150px]">
{row.original.siteId
? selectedSite?.name
: t("siteSelect")}
</span>
<CaretSortIcon className="ml-2h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[180px]">
<Command>
<CommandInput
placeholder={t("siteSearch")}
/>
<CommandList>
<CommandEmpty>
{t("siteNotFound")}
</CommandEmpty>
<CommandGroup>
{sites.map((site) => (
<CommandItem
key={site.siteId}
value={`${site.siteId}:${site.name}`}
onSelect={() =>
updateTarget(
row.original
.targetId,
{
siteId: site.siteId
}
)
}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
row.original
.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{site.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{isHttp && (
<Select
defaultValue={row.original.method ?? "http"}
onValueChange={(value) =>
updateTarget(row.original.targetId, {
...row.original,
method: value
})
}
>
<SelectTrigger className="h-8 px-2 w-[70px] border-none bg-transparent shadow-none focus:ring-0 focus:outline-none focus-visible:ring-0 data-[state=open]:bg-transparent">
{row.original.method || "http"}
</SelectTrigger>
<SelectContent>
<SelectItem value="http">
http
</SelectItem>
<SelectItem value="https">
https
</SelectItem>
<SelectItem value="h2c">h2c</SelectItem>
</SelectContent>
</Select>
)}
{isHttp && (
<div className="flex items-center justify-center px-2 h-9">
{"://"}
</div>
)}
<Input
defaultValue={row.original.ip}
placeholder="Host"
className="flex-1 min-w-[120px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const input = e.target.value.trim();
const hasProtocol =
/^(https?|h2c):\/\//.test(input);
const hasPort = /:\d+(?:\/|$)/.test(input);
if (hasProtocol || hasPort) {
const parsed = parseHostTarget(input);
if (parsed) {
updateTarget(
row.original.targetId,
{
...row.original,
method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host,
port: hasPort
? parsed.port
: row.original.port
}
);
} else {
updateTarget(
row.original.targetId,
{
...row.original,
ip: input
}
);
}
} else {
updateTarget(row.original.targetId, {
...row.original,
ip: input
});
}
}}
/>
<div className="flex items-center justify-center px-2 h-9">
{":"}
</div>
<Input
placeholder="Port"
defaultValue={
row.original.port === 0
? ""
: row.original.port
}
className="w-[75px] pl-0 border-none placeholder-gray-400"
onBlur={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
updateTarget(row.original.targetId, {
...row.original,
port: value
});
} else {
updateTarget(row.original.targetId, {
...row.original,
port: 0
});
}
}}
/>
</div>
</div>
);
},
cell: ({ row }) => (
<ResourceTargetAddressItem
isHttp={isHttp}
sites={sites}
getDockerStateForSite={getDockerStateForSite}
proxyTarget={row.original}
refreshContainersForSite={refreshContainersForSite}
updateTarget={updateTarget}
/>
),
size: 400,
minSize: 350,
maxSize: 500
@@ -1186,7 +849,7 @@ export default function Page() {
<Button
variant="outline"
disabled={noPathMatch}
className="w-full max-w-[200px]"
className="w-full max-w-50"
>
<Plus className="h-4 w-4 mr-2" />
{t("rewritePath")}
@@ -1265,9 +928,17 @@ export default function Page() {
actionsColumn
];
}
};
const columns = getColumns();
}, [
isAdvancedMode,
isHttp,
sites,
updateTarget,
getDockerStateForSite,
refreshContainersForSite,
openHealthCheckDialog,
removeTarget,
t
]);
const table = useReactTable({
data: targets,
@@ -1649,9 +1320,6 @@ export default function Page() {
</TableRow>
)}
</TableBody>
{/* <TableCaption> */}
{/* {t('targetNoOneDescription')} */}
{/* </TableCaption> */}
</Table>
</div>
<div className="flex items-center justify-between mb-4">

View File

@@ -23,6 +23,7 @@ import Script from "next/script";
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
import { TailwindIndicator } from "@app/components/TailwindIndicator";
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
import StoreInternalRedirect from "@app/components/StoreInternalRedirect";
export const metadata: Metadata = {
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
@@ -79,6 +80,7 @@ export default async function RootLayout({
return (
<html suppressHydrationWarning lang={locale}>
<body className={`${font.className} h-screen-safe overflow-hidden`}>
<StoreInternalRedirect />
<TopLoader />
{build === "saas" && (
<Script

View File

@@ -10,6 +10,7 @@ import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
@@ -80,15 +81,16 @@ export default async function Page(props: {
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
let targetOrgId: string | null = null;
if (lastOrgExists && lastOrgCookie) {
redirect(`/${lastOrgCookie}`);
targetOrgId = lastOrgCookie;
} else {
let ownedOrg = orgs.find((org) => org.isOwner);
if (!ownedOrg) {
ownedOrg = orgs[0];
}
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
targetOrgId = ownedOrg.orgId;
} else {
if (!env.flags.disableUserCreateOrg || user.serverAdmin) {
redirect("/setup");
@@ -96,6 +98,10 @@ export default async function Page(props: {
}
}
if (targetOrgId) {
return <RedirectToOrg targetOrgId={targetOrgId} />;
}
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={[]}>

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { consumeInternalRedirectPath } from "@app/lib/internalRedirect";
type ApplyInternalRedirectProps = {
orgId: string;
};
export default function ApplyInternalRedirect({
orgId
}: ApplyInternalRedirectProps) {
const router = useRouter();
useEffect(() => {
const path = consumeInternalRedirectPath();
if (path) {
router.replace(`/${orgId}${path}`);
}
}, [orgId, router]);
return null;
}

View File

@@ -43,25 +43,52 @@ export type AuthPageCustomizationProps = {
const AuthPageFormSchema = z.object({
logoUrl: z.union([
z.string().length(0),
z.url().refine(
async (url) => {
try {
const response = await fetch(url);
return (
response.status === 200 &&
(response.headers.get("content-type") ?? "").startsWith(
"image/"
)
);
} catch (error) {
return false;
z.literal(""),
z.url("Must be a valid URL").superRefine(async (url, ctx) => {
try {
const response = await fetch(url, {
method: "HEAD"
}).catch(() => {
// If HEAD fails (CORS or method not allowed), try GET
return fetch(url, { method: "GET" });
});
if (response.status !== 200) {
ctx.addIssue({
code: "custom",
message: `Failed to load image. Please check that the URL is accessible.`
});
return;
}
},
{
error: "Invalid logo URL, must be a valid image URL"
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.startsWith("image/")) {
ctx.addIssue({
code: "custom",
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
});
return;
}
} catch (error) {
let errorMessage =
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
if (
error instanceof TypeError &&
error.message.includes("fetch")
) {
errorMessage =
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
} else if (error instanceof Error) {
errorMessage = `Error verifying URL: ${error.message}`;
}
ctx.addIssue({
code: "custom",
message: errorMessage
});
}
)
})
]),
logoWidth: z.coerce.number<number>().min(1),
logoHeight: z.coerce.number<number>().min(1),
@@ -405,9 +432,7 @@ export default function AuthPageBrandingForm({
<Button
variant="destructive"
type="submit"
loading={
isUpdatingBranding || isDeletingBranding
}
loading={isDeletingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||
@@ -422,7 +447,7 @@ export default function AuthPageBrandingForm({
<Button
type="submit"
form="auth-page-branding-form"
loading={isUpdatingBranding || isDeletingBranding}
loading={isUpdatingBranding}
disabled={
isUpdatingBranding ||
isDeletingBranding ||

View File

@@ -110,6 +110,15 @@ export default function BlueprintDetailsForm({
Dashboard
</Badge>
)}{" "}
{blueprint.source === "CLI" && (
<Badge
variant="secondary"
className="inline-flex items-center gap-1 "
>
<Terminal className="w-3 h-3 flex-none" />
CLI
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
<InfoSection>

View File

@@ -128,6 +128,19 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
</Badge>
);
}
case "CLI": {
return (
<Badge
variant="secondary"
className="inline-flex items-center gap-1"
>
<span className="inline-flex items-center gap-1 ">
<Terminal className="w-3 h-3" />
CLI
</span>
</Badge>
);
}
}
}
},

View File

@@ -4,6 +4,7 @@ import React from "react";
import { Button } from "@app/components/ui/button";
import { Download } from "lucide-react";
import { FaApple, FaWindows, FaLinux } from "react-icons/fa";
import { SiAndroid } from "react-icons/si";
import { useTranslations } from "next-intl";
import Link from "next/link";
import DismissableBanner from "./DismissableBanner";
@@ -61,6 +62,34 @@ export const ClientDownloadBanner = () => {
Linux
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/ios"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<FaApple className="w-4 h-4" />
iOS
</Button>
</Link>
<Link
href="https://pangolin.net/downloads/android"
target="_blank"
rel="noopener noreferrer"
>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<SiAndroid className="w-4 h-4" />
Android
</Button>
</Link>
</DismissableBanner>
);
};

View File

@@ -8,6 +8,7 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useTranslations } from "next-intl";
type ClientInfoCardProps = {};
@@ -16,6 +17,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
const { client, updateClient } = useClientContext();
const t = useTranslations();
const userDisplayName = getUserDisplayName({
email: client.userEmail,
name: client.userName,
username: client.userUsername
});
return (
<Alert>
<AlertDescription>
@@ -25,8 +32,12 @@ export default function SiteInfoCard({}: ClientInfoCardProps) {
<InfoSectionContent>{client.name}</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
<InfoSectionContent>{client.niceId}</InfoSectionContent>
<InfoSectionTitle>
{userDisplayName ? t("user") : t("identifier")}
</InfoSectionTitle>
<InfoSectionContent>
{userDisplayName || client.niceId}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>{t("status")}</InfoSectionTitle>

View File

@@ -94,12 +94,6 @@ export default function DomainPicker({
const api = createApiClient({ env });
const t = useTranslations();
console.log({
defaultFullDomain,
defaultSubdomain,
defaultDomainId
});
const { data = [], isLoading: loadingDomains } = useQuery(
orgQueries.domains({ orgId })
);
@@ -369,9 +363,6 @@ export default function DomainPicker({
setSelectedProvidedDomain(null);
}
console.log({
setSelectedBaseDomain: option
});
setSelectedBaseDomain(option);
setOpen(false);
@@ -442,9 +433,6 @@ export default function DomainPicker({
0,
providedDomainsShown
);
console.log({
displayedProvidedOptions
});
const selectedDomainNamespaceId =
selectedProvidedDomain?.domainNamespaceId ??

View File

@@ -250,20 +250,41 @@ export default function GenerateLicenseKeyForm({
const submitLicenseRequest = async (payload: any) => {
setLoading(true);
try {
const response = await api.put<
AxiosResponse<GenerateNewLicenseResponse>
>(`/org/${orgId}/license`, payload);
// Check if this is a business/enterprise license request
if (payload.useCaseType === "business") {
const response = await api.put<
AxiosResponse<string>
>(`/org/${orgId}/license/enterprise`, { ...payload, tier: "big_license" } );
if (response.data.data?.licenseKey?.licenseKey) {
setGeneratedKey(response.data.data.licenseKey.licenseKey);
onGenerated?.();
toast({
title: t("generateLicenseKeyForm.toasts.success.title"),
description: t(
"generateLicenseKeyForm.toasts.success.description"
),
variant: "default"
});
console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data;
if (checkoutUrl) {
window.location.href = checkoutUrl;
} else {
toast({
title: "Failed to get checkout URL",
description: "Please try again later",
variant: "destructive"
});
setLoading(false);
}
} else {
// Personal license flow
const response = await api.put<
AxiosResponse<GenerateNewLicenseResponse>
>(`/org/${orgId}/license`, payload);
if (response.data.data?.licenseKey?.licenseKey) {
setGeneratedKey(response.data.data.licenseKey.licenseKey);
onGenerated?.();
toast({
title: t("generateLicenseKeyForm.toasts.success.title"),
description: t(
"generateLicenseKeyForm.toasts.success.description"
),
variant: "default"
});
}
}
} catch (e) {
console.error(e);
@@ -1066,16 +1087,16 @@ export default function GenerateLicenseKeyForm({
)}
{!generatedKey && useCaseType === "business" && (
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
<Button
type="submit"
form="generate-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>

View File

@@ -10,12 +10,12 @@ import { Badge } from "./ui/badge";
import moment from "moment";
import { DataTable } from "./ui/data-table";
import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm";
import NewPricingLicenseForm from "./NewPricingLicenseForm";
type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[];
@@ -29,12 +29,15 @@ function obfuscateLicenseKey(key: string): string {
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
const GENERATE_QUERY = "generate";
export default function GenerateLicenseKeysTable({
licenseKeys,
orgId
}: GnerateLicenseKeysTableProps) {
const t = useTranslations();
const router = useRouter();
const searchParams = useSearchParams();
const { env } = useEnvContext();
const api = createApiClient({ env });
@@ -42,6 +45,19 @@ export default function GenerateLicenseKeysTable({
const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = useState(false);
useEffect(() => {
if (searchParams.get(GENERATE_QUERY) !== null) {
setShowGenerateForm(true);
const next = new URLSearchParams(searchParams);
next.delete(GENERATE_QUERY);
const qs = next.toString();
const url = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", url);
}
}, [searchParams]);
const handleLicenseGenerated = () => {
// Refresh the data after license is generated
refreshData();
@@ -158,6 +174,48 @@ export default function GenerateLicenseKeysTable({
: t("licenseTierPersonal");
}
},
{
accessorKey: "users",
friendlyName: t("users"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("users")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const users = row.original.users;
return users === -1 ? "∞" : users;
}
},
{
accessorKey: "sites",
friendlyName: t("sites"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("sites")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const sites = row.original.sites;
return sites === -1 ? "∞" : sites;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
@@ -198,7 +256,7 @@ export default function GenerateLicenseKeysTable({
}}
/>
<GenerateLicenseKeyForm
<NewPricingLicenseForm
open={showGenerateForm}
setOpen={setShowGenerateForm}
orgId={orgId}

View File

@@ -143,7 +143,6 @@ export default function LoginOrgSelector({
<IdpLoginButtons
idps={idps}
redirect={redirect}
orgId={org.orgId}
/>
</div>
</div>

View File

@@ -0,0 +1,913 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { Checkbox } from "@app/components/ui/checkbox";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { AxiosResponse } from "axios";
import { useState } from "react";
import { useForm, type Resolver } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
import { useTranslations } from "next-intl";
import React from "react";
import { StrategySelect, StrategyOption } from "./StrategySelect";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { InfoIcon } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
const TIER_TO_LICENSE_ID = {
starter: "small_license",
scale: "big_license"
} as const;
type FormProps = {
open: boolean;
setOpen: (open: boolean) => void;
orgId: string;
onGenerated?: () => void;
};
export default function NewPricingLicenseForm({
open,
setOpen,
orgId,
onGenerated
}: FormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const { user } = useUserContext();
const [loading, setLoading] = useState(false);
const [generatedKey, setGeneratedKey] = useState<string | null>(null);
const [personalUseOnly, setPersonalUseOnly] = useState(false);
const [selectedTier, setSelectedTier] = useState<"starter" | "scale">(
"starter"
);
const personalFormSchema = z.object({
email: z.email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
primaryUse: z.string().min(1),
country: z.string().min(1),
phoneNumber: z.string().optional(),
agreedToTerms: z.boolean().refine((val) => val === true),
complianceConfirmed: z.boolean().refine((val) => val === true)
});
const businessFormSchema = z.object({
email: z.email(),
firstName: z.string().min(1),
lastName: z.string().min(1),
primaryUse: z.string().min(1),
industry: z.string().min(1),
companyName: z.string().min(1),
companyWebsite: z.string().optional(),
companyPhoneNumber: z.string().optional(),
agreedToTerms: z.boolean().refine((val) => val === true),
complianceConfirmed: z.boolean().refine((val) => val === true)
});
type PersonalFormData = z.infer<typeof personalFormSchema>;
type BusinessFormData = z.infer<typeof businessFormSchema>;
const personalForm = useForm<PersonalFormData>({
resolver: zodResolver(personalFormSchema) as Resolver<PersonalFormData>,
defaultValues: {
email: user?.email || "",
firstName: "",
lastName: "",
primaryUse: "",
country: "",
phoneNumber: "",
agreedToTerms: false,
complianceConfirmed: false
}
});
const businessForm = useForm<BusinessFormData>({
resolver: zodResolver(businessFormSchema) as Resolver<BusinessFormData>,
defaultValues: {
email: user?.email || "",
firstName: "",
lastName: "",
primaryUse: "",
industry: "",
companyName: "",
companyWebsite: "",
companyPhoneNumber: "",
agreedToTerms: false,
complianceConfirmed: false
}
});
React.useEffect(() => {
if (open) {
resetForm();
setGeneratedKey(null);
setPersonalUseOnly(false);
setSelectedTier("starter");
}
}, [open]);
function resetForm() {
personalForm.reset({
email: user?.email || "",
firstName: "",
lastName: "",
primaryUse: "",
country: "",
phoneNumber: "",
agreedToTerms: false,
complianceConfirmed: false
});
businessForm.reset({
email: user?.email || "",
firstName: "",
lastName: "",
primaryUse: "",
industry: "",
companyName: "",
companyWebsite: "",
companyPhoneNumber: "",
agreedToTerms: false,
complianceConfirmed: false
});
}
const tierOptions: StrategyOption<"starter" | "scale">[] = [
{
id: "starter",
title: t("newPricingLicenseForm.tiers.starter.title"),
description: t("newPricingLicenseForm.tiers.starter.description")
},
{
id: "scale",
title: t("newPricingLicenseForm.tiers.scale.title"),
description: t("newPricingLicenseForm.tiers.scale.description")
}
];
const submitLicenseRequest = async (
payload: Record<string, unknown>
): Promise<void> => {
setLoading(true);
try {
// Check if this is a business/enterprise license request
if (!personalUseOnly) {
const response = await api.put<AxiosResponse<string>>(
`/org/${orgId}/license/enterprise`,
{ ...payload, tier: TIER_TO_LICENSE_ID[selectedTier] }
);
console.log("Checkout session response:", response.data);
const checkoutUrl = response.data.data;
if (checkoutUrl) {
window.location.href = checkoutUrl;
} else {
toast({
title: "Failed to get checkout URL",
description: "Please try again later",
variant: "destructive"
});
setLoading(false);
}
} else {
// Personal license flow
const response = await api.put<
AxiosResponse<GenerateNewLicenseResponse>
>(`/org/${orgId}/license`, payload);
if (response.data.data?.licenseKey?.licenseKey) {
setGeneratedKey(response.data.data.licenseKey.licenseKey);
onGenerated?.();
toast({
title: t("generateLicenseKeyForm.toasts.success.title"),
description: t(
"generateLicenseKeyForm.toasts.success.description"
),
variant: "default"
});
}
}
} catch (e) {
console.error(e);
toast({
title: t("generateLicenseKeyForm.toasts.error.title"),
description: formatAxiosError(
e,
t("generateLicenseKeyForm.toasts.error.description")
),
variant: "destructive"
});
}
setLoading(false);
};
const onSubmitPersonal = async (values: PersonalFormData) => {
await submitLicenseRequest({
email: values.email,
useCaseType: "personal",
personal: {
firstName: values.firstName,
lastName: values.lastName,
aboutYou: { primaryUse: values.primaryUse },
personalInfo: {
country: values.country,
phoneNumber: values.phoneNumber || ""
}
},
business: undefined,
consent: {
agreedToTerms: values.agreedToTerms,
acknowledgedPrivacyPolicy: values.agreedToTerms,
complianceConfirmed: values.complianceConfirmed
}
});
};
const onSubmitBusiness = async (values: BusinessFormData) => {
const payload = {
email: values.email,
useCaseType: "business",
personal: undefined,
business: {
firstName: values.firstName,
lastName: values.lastName,
jobTitle: "N/A",
aboutYou: {
primaryUse: values.primaryUse,
industry: values.industry,
prospectiveUsers: 100,
prospectiveSites: 100
},
companyInfo: {
companyName: values.companyName,
countryOfResidence: "N/A",
stateProvinceRegion: "N/A",
postalZipCode: "N/A",
companyWebsite: values.companyWebsite || "",
companyPhoneNumber: values.companyPhoneNumber || ""
}
},
consent: {
agreedToTerms: values.agreedToTerms,
acknowledgedPrivacyPolicy: values.agreedToTerms,
complianceConfirmed: values.complianceConfirmed
}
};
await submitLicenseRequest(payload);
};
const handleClose = () => {
setOpen(false);
setGeneratedKey(null);
resetForm();
};
return (
<Credenza open={open} onOpenChange={handleClose}>
<CredenzaContent className="max-w-4xl">
<CredenzaHeader>
<CredenzaTitle>
{t("newPricingLicenseForm.title")}
</CredenzaTitle>
<CredenzaDescription>
{t("newPricingLicenseForm.description")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
{generatedKey ? (
<div className="space-y-4">
<CopyTextBox
text={generatedKey}
wrapText={false}
/>
</div>
) : (
<>
{/* Tier selection - required when not personal use */}
{!personalUseOnly && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t(
"newPricingLicenseForm.chooseTier"
)}
</label>
<StrategySelect
options={tierOptions}
defaultValue={selectedTier}
onChange={(value) =>
setSelectedTier(value)
}
cols={2}
/>
<a
href="https://pangolin.net/pricing"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t(
"newPricingLicenseForm.viewPricingLink"
)}
</a>
</div>
)}
{/* Personal use only checkbox at the bottom of options */}
<div className="flex items-center space-x-2">
<Checkbox
id="personal-use-only"
checked={personalUseOnly}
onCheckedChange={(checked) => {
setPersonalUseOnly(
checked === true
);
if (checked) {
businessForm.reset();
} else {
personalForm.reset();
}
}}
/>
<label
htmlFor="personal-use-only"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"newPricingLicenseForm.personalUseOnly"
)}
</label>
</div>
{/* License disclosure - only when personal use */}
{personalUseOnly && (
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle>
{t(
"generateLicenseKeyForm.alerts.commercialUseDisclosure.title"
)}
</AlertTitle>
<AlertDescription>
{t(
"generateLicenseKeyForm.alerts.commercialUseDisclosure.description"
)
.split(
"Fossorial Commercial License Terms"
)
.map((part, index) => (
<span key={index}>
{part}
{index === 0 && (
<a
href="https://pangolin.net/fcl.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
Fossorial
Commercial
License Terms
</a>
)}
</span>
))}
</AlertDescription>
</Alert>
)}
{/* Personal form: only when personal use only is checked */}
{personalUseOnly && (
<Form {...personalForm}>
<form
onSubmit={personalForm.handleSubmit(
onSubmitPersonal
)}
className="space-y-4"
id="new-pricing-license-personal-form"
>
<div className="grid grid-cols-2 gap-4">
<FormField
control={
personalForm.control
}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.firstName"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
personalForm.control
}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.lastName"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={personalForm.control}
name="primaryUse"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.primaryUseQuestion"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={
personalForm.control
}
name="country"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.country"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
personalForm.control
}
name="phoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.phoneNumberOptional"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-4">
<FormField
control={
personalForm.control
}
name="agreedToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t(
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={
personalForm.control
}
name="complianceConfirmed"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"generateLicenseKeyForm.form.complianceConfirmation"
)}{" "}
<a
href="https://pangolin.net/fcl.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{/* Business form: when not personal use - enter business info then continue to checkout */}
{!personalUseOnly && (
<Form {...businessForm}>
<form
onSubmit={businessForm.handleSubmit(
onSubmitBusiness
)}
className="space-y-4"
id="new-pricing-license-business-form"
>
<div className="grid grid-cols-2 gap-4">
<FormField
control={
businessForm.control
}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.firstName"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
businessForm.control
}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.lastName"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={businessForm.control}
name="primaryUse"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.primaryUseQuestion"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={businessForm.control}
name="industry"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.industryQuestion"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={businessForm.control}
name="companyName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.companyName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={
businessForm.control
}
name="companyWebsite"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.companyWebsite"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
businessForm.control
}
name="companyPhoneNumber"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"generateLicenseKeyForm.form.companyPhoneNumber"
)}
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="space-y-4 pt-4">
<FormField
control={
businessForm.control
}
name="agreedToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"signUpTerms.IAgreeToThe"
)}{" "}
<a
href="https://pangolin.net/terms-of-service.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.termsOfService"
)}{" "}
</a>
{t(
"signUpTerms.and"
)}{" "}
<a
href="https://pangolin.net/privacy-policy.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{t(
"signUpTerms.privacyPolicy"
)}
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={
businessForm.control
}
name="complianceConfirmed"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="text-sm font-normal">
<div>
{t(
"generateLicenseKeyForm.form.complianceConfirmation"
)}{" "}
<a
href="https://pangolin.net/fcl.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
https://pangolin.net/fcl.html
</a>
</div>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
</>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">
{t("generateLicenseKeyForm.buttons.close")}
</Button>
</CredenzaClose>
{!generatedKey && personalUseOnly && (
<Button
type="submit"
form="new-pricing-license-personal-form"
disabled={loading}
loading={loading}
>
{t(
"generateLicenseKeyForm.buttons.generateLicenseKey"
)}
</Button>
)}
{!generatedKey && !personalUseOnly && (
<Button
type="submit"
form="new-pricing-license-business-form"
disabled={loading}
loading={loading}
>
{t(
"newPricingLicenseForm.buttons.continueToCheckout"
)}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { getInternalRedirectTarget } from "@app/lib/internalRedirect";
type RedirectToOrgProps = {
targetOrgId: string;
};
export default function RedirectToOrg({ targetOrgId }: RedirectToOrgProps) {
const router = useRouter();
useEffect(() => {
try {
const target = getInternalRedirectTarget(targetOrgId);
router.replace(target);
} catch {
router.replace(`/${targetOrgId}`);
}
}, [targetOrgId, router]);
return null;
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
import { INTERNAL_REDIRECT_KEY } from "@app/lib/internalRedirect";
const TTL_MS = 10 * 60 * 1000; // 10 minutes
export default function StoreInternalRedirect() {
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const value = params.get("internal_redirect");
if (value != null && value !== "") {
try {
const payload = JSON.stringify({
path: value,
expiresAt: Date.now() + TTL_MS
});
window.localStorage.setItem(INTERNAL_REDIRECT_KEY, payload);
} catch {
// ignore
}
}
}, []);
return null;
}

View File

@@ -226,6 +226,21 @@ export default function SupporterStatus({
</Link>
</p>
<div className="my-4 p-4 border border-blue-500/50 bg-blue-500/10 rounded-lg">
<p className="text-sm">
<strong>Business & Enterprise Users:</strong> For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "}
<Link
href="https://pangolin.net/pricing?hosting=self-host"
target="_blank"
rel="noopener noreferrer"
className="underline inline-flex items-center gap-1"
>
Learn more
<ExternalLink className="h-3 w-3" />
</Link>
</p>
</div>
<div className="py-6">
<p className="mb-3 text-center">
{t("supportKeyOptions")}

Some files were not shown because too many files have changed in this diff Show More