mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-17 16:19:48 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 313b10ce21 | |||
| b0e2fcadd4 | |||
| 65ccd5a89d | |||
| 76a4f50ccf | |||
| 1dfe0124be | |||
| aa52174f96 | |||
| e23ae5707f | |||
| 17053ac2bd |
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
|
||||
* Create reusable views for common access patterns
|
||||
|
||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
||||
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
|
||||
|
||||
## Download Clients
|
||||
|
||||
|
||||
@@ -234,6 +234,9 @@
|
||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||
"privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.",
|
||||
"licenseBillingBannerTitle": "Manage License Billing",
|
||||
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
|
||||
"licenseBillingBannerButton": "Go to Billing",
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
|
||||
Generated
+129
-32
@@ -43,10 +43,10 @@
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"@simplewebauthn/server": "14.0.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-table": "9.2.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -73,7 +73,7 @@
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"js-yaml": "5.4.2",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lru-cache": "11.5.2",
|
||||
"lucide-react": "1.45.0",
|
||||
@@ -100,7 +100,7 @@
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.6.2",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
@@ -112,7 +112,7 @@
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.1",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.6.2",
|
||||
"zod": "4.6.5",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -5646,9 +5646,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.1.tgz",
|
||||
"integrity": "sha512-kjWgcm9NdSv+Tobo4Swxd1WnEawsBogpYFMjg9pKsVLPN3FdCmR6BipkL+DcPDgmQ7UKbbBZqRqn3E3YaA7NBw==",
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.2.tgz",
|
||||
"integrity": "sha512-g+m/xv9/8FRWsDY22CwsTYRzt3WwS9fJrR3P7eY3Ti01FXH5FQ36Xn55v6D+5tb6VajlqOj4hywP/Vxsb6a3Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hexagon/base64": "^1.1.27",
|
||||
@@ -6297,6 +6297,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -6400,24 +6466,42 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||
"node_modules/@tanstack/react-store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz",
|
||||
"integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/table-core": "8.21.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"@tanstack/store": "0.11.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-table": {
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.2.4.tgz",
|
||||
"integrity": "sha512-Rzp1Q4e0/nIgEjmISYR5HeEgLTNtrG+C7NFZf/AbCxPO5hg+zC3yNGwcoECigUk8SFzzvhXbd2rFdtP2ZiGrfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/react-store": "^0.11.1",
|
||||
"@tanstack/table-core": "9.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
@@ -6437,13 +6521,26 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||
"node_modules/@tanstack/store": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
|
||||
"integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/table-core": {
|
||||
"version": "9.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.2.4.tgz",
|
||||
"integrity": "sha512-GwdDyGGr6UXAtubF14yAwcXvdaogqfsQgIk89Suebjxob/Rjq2xvfmXsjDF1rQmKmhHsJm9TOY7myxv3i6geJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/store": "^0.11.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -12850,9 +12947,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
|
||||
"integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
|
||||
"version": "5.4.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz",
|
||||
"integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -16663,9 +16760,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz",
|
||||
"integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -17858,9 +17955,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz",
|
||||
"integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==",
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
+5
-5
@@ -66,10 +66,10 @@
|
||||
"@react-email/render": "2.1.0",
|
||||
"@react-email/tailwind": "2.0.7",
|
||||
"@simplewebauthn/browser": "14.0.0",
|
||||
"@simplewebauthn/server": "14.0.1",
|
||||
"@simplewebauthn/server": "14.0.2",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@tanstack/react-query": "5.102.8",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-table": "9.2.4",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
"input-otp": "1.5.0",
|
||||
"ioredis": "6.0.0",
|
||||
"jmespath": "0.16.0",
|
||||
"js-yaml": "5.4.1",
|
||||
"js-yaml": "5.4.2",
|
||||
"jsonwebtoken": "9.0.3",
|
||||
"lru-cache": "11.5.2",
|
||||
"lucide-react": "1.45.0",
|
||||
@@ -123,7 +123,7 @@
|
||||
"sshpk": "1.18.0",
|
||||
"stripe": "22.6.2",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwind-merge": "3.7.0",
|
||||
"topojson-client": "3.1.0",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"use-debounce": "10.1.1",
|
||||
@@ -135,7 +135,7 @@
|
||||
"ws": "8.21.3",
|
||||
"yaml": "2.9.1",
|
||||
"yargs": "18.1.0",
|
||||
"zod": "4.6.2",
|
||||
"zod": "4.6.5",
|
||||
"zod-validation-error": "5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 620 KiB After Width: | Height: | Size: 713 KiB |
@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
|
||||
</EmailSection>
|
||||
|
||||
<EmailText>
|
||||
If you need to purchase additional license keys or
|
||||
modify your existing license, please reach out to
|
||||
our support team at{" "}
|
||||
For any questions or concerns regarding your license
|
||||
or billing, please reach out to support at{" "}
|
||||
<a
|
||||
href="mailto:support@pangolin.net"
|
||||
className="text-primary font-medium"
|
||||
|
||||
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
|
||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
||||
);
|
||||
|
||||
// Grace period of 5 days added on top of the current billing
|
||||
// period end (usually ~1 year out) before the license expires
|
||||
const currentPeriodEnd =
|
||||
fullSubscription.items.data[0]?.current_period_end;
|
||||
const expiresAt =
|
||||
(currentPeriodEnd ?? subscription.created) +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
const response = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||
{
|
||||
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
|
||||
paidFor: true,
|
||||
users: numUsers,
|
||||
sites: numSites,
|
||||
tier: tier
|
||||
tier: tier,
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
@@ -267,6 +276,13 @@ export async function handleSubscriptionCreated(
|
||||
|
||||
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (customer.email) {
|
||||
logger.debug(
|
||||
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||
|
||||
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
|
||||
`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(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
} from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||
import {
|
||||
getFeatureIdByMetricId,
|
||||
getFeatureIdByPriceId
|
||||
} from "@server/lib/billing/features";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { getSubType, SubscriptionType } from "./getSubType";
|
||||
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
|
||||
.limit(1);
|
||||
|
||||
const type = getSubType(fullSubscription);
|
||||
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||
const previousType =
|
||||
existingSubscription.type as SubscriptionType | null;
|
||||
|
||||
// If the subscription has been manually overridden, we lock the
|
||||
// status down so Stripe webhooks can no longer change it.
|
||||
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||
);
|
||||
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
type,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert subscription items
|
||||
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
|
||||
|
||||
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||
// Try to get featureId from price
|
||||
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||
let featureId: string | null =
|
||||
getFeatureIdByPriceId(item.price.id) || null;
|
||||
|
||||
// If no match, try to preserve existing featureId
|
||||
if (!featureId) {
|
||||
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
|
||||
logger.info(
|
||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
||||
);
|
||||
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||
await handleTierChange(
|
||||
customer.orgId,
|
||||
null,
|
||||
previousType ?? undefined
|
||||
);
|
||||
}
|
||||
} else if (type === "license") {
|
||||
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||
if (
|
||||
effectiveStatus === "canceled" ||
|
||||
effectiveStatus == "unpaid" ||
|
||||
effectiveStatus == "incomplete_expired"
|
||||
) {
|
||||
try {
|
||||
// WARNING:
|
||||
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||
await fetch(
|
||||
const invalidateResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||
{
|
||||
method: "POST",
|
||||
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orgId: customer.orgId
|
||||
orgId: customer.orgId,
|
||||
licenseKeyId: parseInt(
|
||||
subscription.metadata.licenseKeyId
|
||||
)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!invalidateResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
} else if (effectiveStatus === "active" && previousAttributes) {
|
||||
// Detect a successful renewal: the billing period rolled
|
||||
// forward (the invoice was paid and the new period began
|
||||
// right where the previous one ended).
|
||||
const currentItem = fullSubscription.items.data[0];
|
||||
const prevItems = previousAttributes.items?.data;
|
||||
const prevItem = Array.isArray(prevItems)
|
||||
? prevItems.find(
|
||||
(pi: any) => pi.id === currentItem?.id
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const renewed =
|
||||
currentItem &&
|
||||
prevItem?.current_period_end &&
|
||||
currentItem.current_period_start ===
|
||||
prevItem.current_period_end &&
|
||||
currentItem.current_period_start >
|
||||
prevItem.current_period_start;
|
||||
|
||||
if (renewed) {
|
||||
const licenseKeyId =
|
||||
subscription.metadata.licenseKeyId;
|
||||
|
||||
if (!licenseKeyId) {
|
||||
logger.error(
|
||||
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
|
||||
);
|
||||
} else {
|
||||
// Grace period of 5 days added on top of the new
|
||||
// billing period end (usually ~1 year out)
|
||||
const expiresAt =
|
||||
currentItem.current_period_end +
|
||||
5 * 24 * 60 * 60;
|
||||
|
||||
try {
|
||||
const extendResponse = await fetch(
|
||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"api-key":
|
||||
privateConfig.getRawPrivateConfig()
|
||||
.server.fossorial_api_key!,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseId: parseInt(licenseKeyId),
|
||||
expiresAt: expiresAt
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!extendResponse.ok) {
|
||||
logger.error(
|
||||
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
|
||||
);
|
||||
}
|
||||
|
||||
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
|
||||
|
||||
// check if we already have a customer for this org
|
||||
const [customer] = await db
|
||||
.select()
|
||||
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
|
||||
], // Start with the standard feature set that matches the free limits
|
||||
customer: customer.customerId,
|
||||
mode: "subscription",
|
||||
subscription_data: {
|
||||
description: licenseKeyValue
|
||||
? `License ${licenseKeyValue}`
|
||||
: `License key ID ${keyId}`,
|
||||
metadata: {
|
||||
licenseKeyId: keyId.toString(),
|
||||
licenseKey: licenseKeyValue ?? "",
|
||||
tier: licenseData.tier
|
||||
}
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
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`
|
||||
|
||||
@@ -749,12 +749,6 @@ export default function BillingPage() {
|
||||
return 0;
|
||||
};
|
||||
|
||||
// Get license key count
|
||||
const getLicenseKeyCount = (): number => {
|
||||
if (!licenseSubscription?.items) return 0;
|
||||
return licenseSubscription.items.length;
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (
|
||||
targetTier: Tier | "basic"
|
||||
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
|
||||
</SettingsSection>
|
||||
|
||||
{/* Paid License Keys Section */}
|
||||
{(licenseSubscription || getLicenseKeyCount() > 0) && (
|
||||
{licenseSubscription && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
|
||||
<SettingsFormGrid>
|
||||
<SettingsFormCell span="full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") ||
|
||||
"Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-semibold">
|
||||
{getLicenseKeyCount()}
|
||||
</span>
|
||||
<span className="text-lg">
|
||||
{getLicenseKeyCount() === 1
|
||||
? "key"
|
||||
: "keys"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleModifySubscription}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
|
||||
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
|
||||
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
|
||||
licenseKeys = data.data.data;
|
||||
} catch {}
|
||||
|
||||
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
|
||||
const hasNonPersonalLicenseKey = licenseKeys.some(
|
||||
(key) => key.tier !== "personal"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasNonPersonalLicenseKey && (
|
||||
<LicenseBillingBanner orgId={orgId} />
|
||||
)}
|
||||
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Globe, CreditCard, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
type LicenseBillingBannerProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DismissableBanner
|
||||
storageKey="license-billing-banner-dismissed"
|
||||
version={1}
|
||||
title={t("licenseBillingBannerTitle")}
|
||||
titleIcon={<Globe className="w-5 h-5 text-primary" />}
|
||||
description={t("licenseBillingBannerDescription")}
|
||||
>
|
||||
<Link href={`/${orgId}/settings/billing`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<CreditCard className="w-4 h-4" />
|
||||
{t("licenseBillingBannerButton")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseBillingBanner;
|
||||
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
|
||||
cols={2}
|
||||
/>
|
||||
<a
|
||||
href="https://pangolin.net/pricing"
|
||||
href="https://pangolin.net/pricing#Self-Hosted"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
|
||||
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
) : null;
|
||||
|
||||
if (site.type === "newt") {
|
||||
// agent and agentVersion were added after newtVersion, so a
|
||||
// site still running an older Newt reports only newtVersion.
|
||||
// Without these fallbacks the badge renders with no label and
|
||||
// no version at all.
|
||||
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
|
||||
const agentVersion = site.agentVersion ?? site.newtVersion;
|
||||
return (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.agent == "newt" ? "Newt" : null}
|
||||
{site.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: null}{" "}
|
||||
{site.agentVersion
|
||||
? `v${site.agentVersion}`
|
||||
: "-"}
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{agentLabel}</span>
|
||||
{agentVersion && (
|
||||
<span>v{agentVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
{endpointSection}
|
||||
|
||||
@@ -373,7 +373,7 @@ export default function SitesTable({
|
||||
accessorKey: "type",
|
||||
friendlyName: t("agent"),
|
||||
header: () => {
|
||||
return <span className="p-3">{t("type")}</span>;
|
||||
return <span className="p-3">{t("agent")}</span>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
@@ -395,7 +395,9 @@ export default function SitesTable({
|
||||
// Without these fallbacks the badge renders with no label and
|
||||
// no version at all.
|
||||
const agentLabel =
|
||||
originalRow.agent == "cli" ? "Pangolin CLI" : "Newt";
|
||||
originalRow.agent == "cli"
|
||||
? "Pangolin CLI"
|
||||
: "Newt";
|
||||
const agentVersion =
|
||||
originalRow.agentVersion ?? originalRow.newtVersion;
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user