mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-18 08:39:51 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2733ae1122 | |||
| 2fb3507aa5 |
@@ -142,7 +142,6 @@ Give users a landing page to quickly find and open the resources they can access
|
|||||||
* Create reusable views for common access patterns
|
* 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.png" alt="Resource Launcher" width="100%" />
|
||||||
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
|
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -132,7 +132,7 @@
|
|||||||
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
|
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
|
||||||
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
|
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
|
||||||
"siteRestarted": "Site restarted",
|
"siteRestarted": "Site restarted",
|
||||||
"siteRestartedDescription": "The site has been restarted.",
|
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
|
||||||
"siteErrorRestart": "Failed to restart site",
|
"siteErrorRestart": "Failed to restart site",
|
||||||
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
||||||
"siteSettingDescription": "Configure the settings on the site",
|
"siteSettingDescription": "Configure the settings on the site",
|
||||||
@@ -234,9 +234,6 @@
|
|||||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"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.",
|
"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...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Error deleting resource",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 711 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 713 KiB After Width: | Height: | Size: 620 KiB |
@@ -93,8 +93,9 @@ export const EnterpriseEditionKeyGenerated = ({
|
|||||||
</EmailSection>
|
</EmailSection>
|
||||||
|
|
||||||
<EmailText>
|
<EmailText>
|
||||||
For any questions or concerns regarding your license
|
If you need to purchase additional license keys or
|
||||||
or billing, please reach out to support at{" "}
|
modify your existing license, please reach out to
|
||||||
|
our support team at{" "}
|
||||||
<a
|
<a
|
||||||
href="mailto:support@pangolin.net"
|
href="mailto:support@pangolin.net"
|
||||||
className="text-primary font-medium"
|
className="text-primary font-medium"
|
||||||
|
|||||||
@@ -243,14 +243,6 @@ export async function handleSubscriptionCreated(
|
|||||||
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
|
`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(
|
const response = await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
|
||||||
{
|
{
|
||||||
@@ -266,8 +258,7 @@ export async function handleSubscriptionCreated(
|
|||||||
paidFor: true,
|
paidFor: true,
|
||||||
users: numUsers,
|
users: numUsers,
|
||||||
sites: numSites,
|
sites: numSites,
|
||||||
tier: tier,
|
tier: tier
|
||||||
expiresAt: expiresAt
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -276,13 +267,6 @@ export async function handleSubscriptionCreated(
|
|||||||
|
|
||||||
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
|
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) {
|
if (customer.email) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
`Sending license key email to ${customer.email} for subscription ${subscription.id}`
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ export async function handleSubscriptionDeleted(
|
|||||||
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const invalidateResponse = await fetch(
|
// WARNING:
|
||||||
|
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||||
|
await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -137,18 +139,9 @@ export async function handleSubscriptionDeleted(
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
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) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import {
|
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features";
|
||||||
getFeatureIdByMetricId,
|
|
||||||
getFeatureIdByPriceId
|
|
||||||
} from "@server/lib/billing/features";
|
|
||||||
import stripe from "#private/lib/stripe";
|
import stripe from "#private/lib/stripe";
|
||||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||||
import { getSubType, SubscriptionType } from "./getSubType";
|
import { getSubType, SubscriptionType } from "./getSubType";
|
||||||
@@ -69,8 +66,7 @@ export async function handleSubscriptionUpdated(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const type = getSubType(fullSubscription);
|
const type = getSubType(fullSubscription);
|
||||||
const previousType =
|
const previousType = existingSubscription.type as SubscriptionType | null;
|
||||||
existingSubscription.type as SubscriptionType | null;
|
|
||||||
|
|
||||||
// If the subscription has been manually overridden, we lock the
|
// If the subscription has been manually overridden, we lock the
|
||||||
// status down so Stripe webhooks can no longer change it.
|
// status down so Stripe webhooks can no longer change it.
|
||||||
@@ -104,11 +100,7 @@ export async function handleSubscriptionUpdated(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
|
||||||
);
|
);
|
||||||
await handleTierChange(
|
await handleTierChange(customer.orgId, type, previousType ?? undefined);
|
||||||
customer.orgId,
|
|
||||||
type,
|
|
||||||
previousType ?? undefined
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upsert subscription items
|
// Upsert subscription items
|
||||||
@@ -121,8 +113,7 @@ export async function handleSubscriptionUpdated(
|
|||||||
|
|
||||||
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
const itemsToUpsert = fullSubscription.items.data.map((item) => {
|
||||||
// Try to get featureId from price
|
// Try to get featureId from price
|
||||||
let featureId: string | null =
|
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null;
|
||||||
getFeatureIdByPriceId(item.price.id) || null;
|
|
||||||
|
|
||||||
// If no match, try to preserve existing featureId
|
// If no match, try to preserve existing featureId
|
||||||
if (!featureId) {
|
if (!featureId) {
|
||||||
@@ -311,20 +302,14 @@ export async function handleSubscriptionUpdated(
|
|||||||
logger.info(
|
logger.info(
|
||||||
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
|
||||||
);
|
);
|
||||||
await handleTierChange(
|
await handleTierChange(customer.orgId, null, previousType ?? undefined);
|
||||||
customer.orgId,
|
|
||||||
null,
|
|
||||||
previousType ?? undefined
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (type === "license") {
|
} else if (type === "license") {
|
||||||
if (
|
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") {
|
||||||
effectiveStatus === "canceled" ||
|
|
||||||
effectiveStatus == "unpaid" ||
|
|
||||||
effectiveStatus == "incomplete_expired"
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const invalidateResponse = await fetch(
|
// WARNING:
|
||||||
|
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
|
||||||
|
await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -335,95 +320,16 @@ export async function handleSubscriptionUpdated(
|
|||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
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) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
|
||||||
error
|
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,8 +96,6 @@ export async function generateNewEnterpriseLicense(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
|
|
||||||
|
|
||||||
// check if we already have a customer for this org
|
// check if we already have a customer for this org
|
||||||
const [customer] = await db
|
const [customer] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -131,16 +129,6 @@ export async function generateNewEnterpriseLicense(
|
|||||||
], // Start with the standard feature set that matches the free limits
|
], // Start with the standard feature set that matches the free limits
|
||||||
customer: customer.customerId,
|
customer: customer.customerId,
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
subscription_data: {
|
|
||||||
description: licenseKeyValue
|
|
||||||
? `License ${licenseKeyValue}`
|
|
||||||
: `License key ID ${keyId}`,
|
|
||||||
metadata: {
|
|
||||||
licenseKeyId: keyId.toString(),
|
|
||||||
licenseKey: licenseKeyValue ?? "",
|
|
||||||
tier: licenseData.tier
|
|
||||||
}
|
|
||||||
},
|
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
|
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`
|
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { sendToClient } from "#dynamic/routers/ws";
|
|
||||||
|
|
||||||
// Error codes for registration failures
|
|
||||||
export const NewtErrorCodes = {
|
|
||||||
NO_AVAILABLE_SUBNET: {
|
|
||||||
code: "NO_AVAILABLE_SUBNET",
|
|
||||||
message:
|
|
||||||
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// Helper function to send registration error
|
|
||||||
export async function sendNewtError(
|
|
||||||
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
|
|
||||||
newtId: string
|
|
||||||
) {
|
|
||||||
sendToClient(newtId, {
|
|
||||||
type: "newt/error",
|
|
||||||
data: {
|
|
||||||
code: error.code,
|
|
||||||
message: error.message
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
|
|||||||
import { fetchContainers } from "./dockerSocket";
|
import { fetchContainers } from "./dockerSocket";
|
||||||
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import { NewtErrorCodes, sendNewtError } from "./error";
|
|
||||||
|
|
||||||
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
@@ -117,7 +116,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
logger.error(
|
logger.error(
|
||||||
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
|
||||||
);
|
);
|
||||||
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,11 +94,6 @@ export const OlmErrorCodes = {
|
|||||||
HOLEPUNCH_MISSING: {
|
HOLEPUNCH_MISSING: {
|
||||||
code: "HOLEPUNCH_MISSING",
|
code: "HOLEPUNCH_MISSING",
|
||||||
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
|
||||||
},
|
|
||||||
NO_AVAILABLE_SUBNET: {
|
|
||||||
code: "NO_AVAILABLE_SUBNET",
|
|
||||||
message:
|
|
||||||
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
|
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -347,7 +347,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
|||||||
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
|
||||||
{ orgId: client.orgId, clientId: client.clientId }
|
{ orgId: client.orgId, clientId: client.clientId }
|
||||||
);
|
);
|
||||||
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -749,6 +749,12 @@ export default function BillingPage() {
|
|||||||
return 0;
|
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
|
// Check if downgrading to a tier would violate current usage limits
|
||||||
const checkLimitViolations = (
|
const checkLimitViolations = (
|
||||||
targetTier: Tier | "basic"
|
targetTier: Tier | "basic"
|
||||||
@@ -1539,7 +1545,7 @@ export default function BillingPage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Paid License Keys Section */}
|
{/* Paid License Keys Section */}
|
||||||
{licenseSubscription && (
|
{(licenseSubscription || getLicenseKeyCount() > 0) && (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -1555,6 +1561,22 @@ export default function BillingPage() {
|
|||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
<SettingsFormCell span="full">
|
<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 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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleModifySubscription}
|
onClick={handleModifySubscription}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
|
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
|
||||||
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
|
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
|
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
|
||||||
@@ -27,16 +26,5 @@ export default async function Page({ params }: Props) {
|
|||||||
licenseKeys = data.data.data;
|
licenseKeys = data.data.data;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const hasNonPersonalLicenseKey = licenseKeys.some(
|
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />;
|
||||||
(key) => key.tier !== "personal"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{hasNonPersonalLicenseKey && (
|
|
||||||
<LicenseBillingBanner orgId={orgId} />
|
|
||||||
)}
|
|
||||||
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
"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}
|
cols={2}
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href="https://pangolin.net/pricing#Self-Hosted"
|
href="https://pangolin.net/pricing"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-primary hover:underline"
|
className="text-sm text-primary hover:underline"
|
||||||
|
|||||||
@@ -63,12 +63,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (site.type === "newt") {
|
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 (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
@@ -85,12 +79,13 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
|
<InfoSectionTitle>{t("agent")}</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
<div className="flex items-center space-x-1">
|
{site.agent == "newt" ? "Newt" : null}
|
||||||
<span>{agentLabel}</span>
|
{site.agent == "cli"
|
||||||
{agentVersion && (
|
? "Pangolin CLI"
|
||||||
<span>v{agentVersion}</span>
|
: null}{" "}
|
||||||
)}
|
{site.agentVersion
|
||||||
</div>
|
? `v${site.agentVersion}`
|
||||||
|
: "-"}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
{endpointSection}
|
{endpointSection}
|
||||||
|
|||||||
@@ -373,7 +373,7 @@ export default function SitesTable({
|
|||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
friendlyName: t("agent"),
|
friendlyName: t("agent"),
|
||||||
header: () => {
|
header: () => {
|
||||||
return <span className="p-3">{t("agent")}</span>;
|
return <span className="p-3">{t("type")}</span>;
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const originalRow = row.original;
|
const originalRow = row.original;
|
||||||
@@ -395,9 +395,7 @@ export default function SitesTable({
|
|||||||
// Without these fallbacks the badge renders with no label and
|
// Without these fallbacks the badge renders with no label and
|
||||||
// no version at all.
|
// no version at all.
|
||||||
const agentLabel =
|
const agentLabel =
|
||||||
originalRow.agent == "cli"
|
originalRow.agent == "cli" ? "Pangolin CLI" : "Newt";
|
||||||
? "Pangolin CLI"
|
|
||||||
: "Newt";
|
|
||||||
const agentVersion =
|
const agentVersion =
|
||||||
originalRow.agentVersion ?? originalRow.newtVersion;
|
originalRow.agentVersion ?? originalRow.newtVersion;
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user