diff --git a/README.md b/README.md index 62259895e..f56a5d78b 100644 --- a/README.md +++ b/README.md @@ -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 Resource Launcher +Resource Launcher Details Panel ## Download Clients diff --git a/messages/en-US.json b/messages/en-US.json index 3095c539a..003595b7b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/public/screenshots/resource-launcher-expanded.png b/public/screenshots/resource-launcher-expanded.png new file mode 100644 index 000000000..0dd64c34e Binary files /dev/null and b/public/screenshots/resource-launcher-expanded.png differ diff --git a/public/screenshots/resource-launcher.png b/public/screenshots/resource-launcher.png index 89150f8c8..c8cc15cd9 100644 Binary files a/public/screenshots/resource-launcher.png and b/public/screenshots/resource-launcher.png differ diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index 82154ab7d..dc3890abe 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({ - 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{" "} ${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 + ); + } + } + } } } } diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts index 2951a7b6e..b882d7170 100644 --- a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -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` diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 0529622e2..7869374cd 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -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() { {/* Paid License Keys Section */} - {(licenseSubscription || getLicenseKeyCount() > 0) && ( + {licenseSubscription && ( @@ -1561,22 +1555,6 @@ export default function BillingPage() {
-
-
- {t("billingCurrentKeys") || - "Current Keys"} -
-
- - {getLicenseKeyCount()} - - - {getLicenseKeyCount() === 1 - ? "key" - : "keys"} - -
-
+ + + ); +}; + +export default LicenseBillingBanner; diff --git a/src/components/NewPricingLicenseForm.tsx b/src/components/NewPricingLicenseForm.tsx index 14a05e7e1..21b608796 100644 --- a/src/components/NewPricingLicenseForm.tsx +++ b/src/components/NewPricingLicenseForm.tsx @@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({ cols={2} />
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("agent")} - {site.agent == "newt" ? "Newt" : null} - {site.agent == "cli" - ? "Pangolin CLI" - : null}{" "} - {site.agentVersion - ? `v${site.agentVersion}` - : "-"} +
+ {agentLabel} + {agentVersion && ( + v{agentVersion} + )} +
{endpointSection} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index f9d541d3f..6e125d1bd 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -373,7 +373,7 @@ export default function SitesTable({ accessorKey: "type", friendlyName: t("agent"), header: () => { - return {t("type")}; + return {t("agent")}; }, 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 (