Merge branch 'self-serve' into dev

This commit is contained in:
Owen
2026-02-04 21:42:32 -08:00
36 changed files with 2107 additions and 180 deletions

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.", "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.", "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.", "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", "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.", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.",
"failed": "Failed", "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", "priority": "Priority",
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual 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", "instanceName": "Instance Name",

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"; 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 ( return (
<div className="inline-block"> <div className="inline-block">
<div className="bg-gray-50 border border-gray-200 rounded-lg px-6 py-4 mx-auto"> <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} {text}
</span> </span>
</div> </div>
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">{hint ?? DEFAULT_HINT}</p>
Copy and paste this code when prompted
</p>
</div> </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

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

View File

@@ -12,7 +12,7 @@
*/ */
import { getTierPriceSet } from "@server/lib/billing/tiers"; 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"; import { build } from "@server/build";
export async function getOrgTierData( export async function getOrgTierData(
@@ -25,22 +25,32 @@ export async function getOrgTierData(
return { tier, active }; 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 subscriptionsWithItems = await getOrgSubscriptionsData(orgId);
const tierPriceSet = getTierPriceSet();
// Iterate through tiers in order (earlier keys are higher tiers) for (const { subscription, items } of subscriptionsWithItems) {
for (const [tierId, priceId] of Object.entries(tierPriceSet)) { if (items && items.length > 0) {
// Check if any subscription item matches this tier's price ID const tierPriceSet = getTierPriceSet();
const matchingItem = items.find((item) => item.priceId === priceId); // Iterate through tiers in order (earlier keys are higher tiers)
if (matchingItem) { for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
tier = tierId; // Check if any subscription item matches this tier's price ID
break; const matchingItem = items.find((item) => item.priceId === priceId);
if (matchingItem) {
tier = tierId;
break;
}
} }
} }
}
if (subscription && subscription.status === "active") { if (subscription && subscription.status === "active") {
active = true; active = true;
}
// If we found a tier and active subscription, we can stop
if (tier && active) {
break;
}
} }
return { tier, active }; return { tier, active };
} }

View File

@@ -45,6 +45,10 @@ export const privateConfigSchema = z.object({
.string() .string()
.optional() .optional()
.transform(getEnvOrYaml("REO_CLIENT_ID")), .transform(getEnvOrYaml("REO_CLIENT_ID")),
fossorial_api: z
.string()
.optional()
.default("https://api.fossorial.io"),
fossorial_api_key: z fossorial_api_key: z
.string() .string()
.optional() .optional()
@@ -164,7 +168,10 @@ export const privateConfigSchema = z.object({
.optional(), .optional(),
stripe: z stripe: z
.object({ .object({
secret_key: z.string().optional().transform(getEnvOrYaml("STRIPE_SECRET_KEY")), secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z webhook_secret: z
.string() .string()
.optional() .optional()

View File

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

View File

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

View File

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

View File

@@ -24,11 +24,22 @@ import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; 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( export async function handleSubscriptionDeleted(
subscription: Stripe.Subscription subscription: Stripe.Subscription
): Promise<void> { ): Promise<void> {
try { 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 const [existingSubscription] = await db
.select() .select()
.from(subscriptions) .from(subscriptions)
@@ -64,24 +75,62 @@ export async function handleSubscriptionDeleted(
return; 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 await handleSubscriptionLifesycle(
.select() customer.orgId,
.from(userOrgs) subscription.status
.where( );
and(
eq(userOrgs.orgId, customer.orgId), const [orgUserRes] = await db
eq(userOrgs.isOwner, true) .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) { if (orgUserRes) {
const email = orgUserRes.user.email; const email = orgUserRes.user.email;
if (email) { if (email) {
moveEmailToAudience(email, AudienceIds.Churned); 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) { } catch (error) {

View File

@@ -26,6 +26,8 @@ import logger from "@server/logger";
import { getFeatureIdByMetricId } from "@server/lib/billing/features"; import { getFeatureIdByMetricId } 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 } from "./getSubType";
import privateConfig from "#private/lib/config";
export async function handleSubscriptionUpdated( export async function handleSubscriptionUpdated(
subscription: Stripe.Subscription, subscription: Stripe.Subscription,
@@ -56,7 +58,7 @@ export async function handleSubscriptionUpdated(
} }
// get the customer // get the customer
const [existingCustomer] = await db const [customer] = await db
.select() .select()
.from(customers) .from(customers)
.where(eq(customers.customerId, subscription.customer as string)) .where(eq(customers.customerId, subscription.customer as string))
@@ -74,11 +76,6 @@ export async function handleSubscriptionUpdated(
}) })
.where(eq(subscriptions.subscriptionId, subscription.id)); .where(eq(subscriptions.subscriptionId, subscription.id));
await handleSubscriptionLifesycle(
existingCustomer.orgId,
subscription.status
);
// Upsert subscription items // Upsert subscription items
if (Array.isArray(fullSubscription.items?.data)) { if (Array.isArray(fullSubscription.items?.data)) {
const itemsToUpsert = fullSubscription.items.data.map((item) => ({ const itemsToUpsert = fullSubscription.items.data.map((item) => ({
@@ -141,20 +138,20 @@ export async function handleSubscriptionUpdated(
// This item has cycled // This item has cycled
const meterId = item.plan.meter; const meterId = item.plan.meter;
if (!meterId) { if (!meterId) {
logger.warn( logger.debug(
`No meterId found for subscription item ${item.id}. Skipping usage reset.` `No meterId found for subscription item ${item.id}. Skipping usage reset.`
); );
continue; continue;
} }
const featureId = getFeatureIdByMetricId(meterId); const featureId = getFeatureIdByMetricId(meterId);
if (!featureId) { if (!featureId) {
logger.warn( logger.debug(
`No featureId found for meterId ${meterId}. Skipping usage reset.` `No featureId found for meterId ${meterId}. Skipping usage reset.`
); );
continue; continue;
} }
const orgId = existingCustomer.orgId; const orgId = customer.orgId;
if (!orgId) { if (!orgId) {
logger.warn( logger.warn(
@@ -236,6 +233,45 @@ export async function handleSubscriptionUpdated(
} }
} }
// --- end usage update --- // --- 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) { } catch (error) {
logger.error( logger.error(

View File

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

View File

@@ -159,11 +159,11 @@ if (build === "saas") {
); );
authenticated.post( authenticated.post(
"/org/:orgId/billing/create-checkout-session", "/org/:orgId/billing/create-checkout-session-saas",
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing), verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing), logActionAudit(ActionsEnum.billing),
billing.createCheckoutSession billing.createCheckoutSessionSAAS
); );
authenticated.post( authenticated.post(
@@ -175,10 +175,10 @@ if (build === "saas") {
); );
authenticated.get( authenticated.get(
"/org/:orgId/billing/subscription", "/org/:orgId/billing/subscriptions",
verifyOrgAccess, verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing), verifyUserHasAction(ActionsEnum.billing),
billing.getOrgSubscription billing.getOrgSubscriptions
); );
authenticated.get( authenticated.get(
@@ -200,6 +200,14 @@ if (build === "saas") {
generateLicense.generateNewLicense generateLicense.generateNewLicense
); );
authenticated.put(
"/org/:orgId/license/enterprise",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.billing),
logActionAudit(ActionsEnum.billing),
generateLicense.generateNewEnterpriseLicense
);
authenticated.post( authenticated.post(
"/send-support-request", "/send-support-request",
rateLimit({ 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 privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; 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 { try {
const response = await fetch( 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", method: "PUT",
headers: { 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; return data;
} catch (error) { } catch (error) {
console.error("Error creating new license:", error); console.error("Error creating new license:", error);

View File

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

View File

@@ -25,7 +25,7 @@ import {
async function fetchLicenseKeys(orgId: string): Promise<any> { async function fetchLicenseKeys(orgId: string): Promise<any> {
try { try {
const response = await fetch( 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", method: "GET",
headers: { headers: {

View File

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

View File

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

View File

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

View File

@@ -43,15 +43,18 @@ import Link from "next/link";
export default function GeneralPage() { export default function GeneralPage() {
const { org } = useOrgContext(); const { org } = useOrgContext();
const api = createApiClient(useEnvContext()); const envContext = useEnvContext();
const api = createApiClient(envContext);
const t = useTranslations(); const t = useTranslations();
// Subscription state // Subscription state - now handling multiple subscriptions
const [subscription, setSubscription] = const [allSubscriptions, setAllSubscriptions] = useState<
useState<GetOrgSubscriptionResponse["subscription"]>(null); GetOrgSubscriptionResponse["subscriptions"]
const [subscriptionItems, setSubscriptionItems] = useState<
GetOrgSubscriptionResponse["items"]
>([]); >([]);
const [tierSubscription, setTierSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [licenseSubscription, setLicenseSubscription] =
useState<GetOrgSubscriptionResponse["subscriptions"][0] | null>(null);
const [subscriptionLoading, setSubscriptionLoading] = useState(true); const [subscriptionLoading, setSubscriptionLoading] = useState(true);
// Example usage data (replace with real usage data if available) // Example usage data (replace with real usage data if available)
@@ -68,12 +71,41 @@ export default function GeneralPage() {
try { try {
const res = await api.get< const res = await api.get<
AxiosResponse<GetOrgSubscriptionResponse> AxiosResponse<GetOrgSubscriptionResponse>
>(`/org/${org.org.orgId}/billing/subscription`); >(`/org/${org.org.orgId}/billing/subscriptions`);
const { subscription, items } = res.data.data; const { subscriptions } = res.data.data;
setSubscription(subscription); setAllSubscriptions(subscriptions);
setSubscriptionItems(items);
// 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( setHasSubscription(
!!subscription && subscription.status === "active" !!tierSub?.subscription && tierSub.subscription.status === "active"
); );
} catch (error) { } catch (error) {
toast({ toast({
@@ -121,7 +153,7 @@ export default function GeneralPage() {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await api.post<AxiosResponse<string>>( 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); console.log("Checkout session response:", response.data);
@@ -302,6 +334,10 @@ export default function GeneralPage() {
return { usage: usage ?? 0, item, limit }; 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 // Helper to check if usage exceeds limit
function isOverLimit(usage: any, limit: any, usageType: any) { function isOverLimit(usage: any, limit: any, usageType: any) {
if (!limit || !usage) return false; if (!limit || !usage) return false;
@@ -388,15 +424,15 @@ export default function GeneralPage() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<Badge <Badge
variant={ 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" /> <CheckCircle className="h-3 w-3 mr-1" />
)} )}
{subscription {tierSubscriptionData
? subscription.status.charAt(0).toUpperCase() + ? tierSubscriptionData.status.charAt(0).toUpperCase() +
subscription.status.slice(1) tierSubscriptionData.status.slice(1)
: t("billingFreeTier")} : t("billingFreeTier")}
</Badge> </Badge>
<Link <Link
@@ -413,7 +449,7 @@ export default function GeneralPage() {
{usageTypes.some((type) => { {usageTypes.some((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -441,7 +477,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { usage, limit } = getUsageItemAndLimit( const { usage, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -530,7 +566,7 @@ export default function GeneralPage() {
{usageTypes.map((type) => { {usageTypes.map((type) => {
const { item, limit } = getUsageItemAndLimit( const { item, limit } = getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -614,7 +650,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -636,7 +672,7 @@ export default function GeneralPage() {
); );
})} })}
{/* Show recurring charges (items with unitAmount but no tiers/meterId) */} {/* Show recurring charges (items with unitAmount but no tiers/meterId) */}
{subscriptionItems {tierSubscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -672,7 +708,7 @@ export default function GeneralPage() {
const { usage, item } = const { usage, item } =
getUsageItemAndLimit( getUsageItemAndLimit(
usageData, usageData,
subscriptionItems, tierSubscriptionItems,
limitsData, limitsData,
type.id type.id
); );
@@ -687,7 +723,7 @@ export default function GeneralPage() {
return sum + cost; return sum + cost;
}, 0) + }, 0) +
// Add recurring charges // Add recurring charges
subscriptionItems tierSubscriptionItems
.filter( .filter(
(item) => (item) =>
item.unitAmount && item.unitAmount &&
@@ -749,6 +785,56 @@ export default function GeneralPage() {
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </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> </SettingsContainer>
); );
} }

View File

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

View File

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

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

View File

@@ -10,12 +10,12 @@ import { Badge } from "./ui/badge";
import moment from "moment"; import moment from "moment";
import { DataTable } from "./ui/data-table"; import { DataTable } from "./ui/data-table";
import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types"; import { GeneratedLicenseKey } from "@server/routers/generatedLicense/types";
import { useState } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import GenerateLicenseKeyForm from "./GenerateLicenseKeyForm"; import NewPricingLicenseForm from "./NewPricingLicenseForm";
type GnerateLicenseKeysTableProps = { type GnerateLicenseKeysTableProps = {
licenseKeys: GeneratedLicenseKey[]; licenseKeys: GeneratedLicenseKey[];
@@ -29,12 +29,15 @@ function obfuscateLicenseKey(key: string): string {
return `${firstPart}••••••••••••••••••••${lastPart}`; return `${firstPart}••••••••••••••••••••${lastPart}`;
} }
const GENERATE_QUERY = "generate";
export default function GenerateLicenseKeysTable({ export default function GenerateLicenseKeysTable({
licenseKeys, licenseKeys,
orgId orgId
}: GnerateLicenseKeysTableProps) { }: GnerateLicenseKeysTableProps) {
const t = useTranslations(); const t = useTranslations();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { env } = useEnvContext(); const { env } = useEnvContext();
const api = createApiClient({ env }); const api = createApiClient({ env });
@@ -42,6 +45,19 @@ export default function GenerateLicenseKeysTable({
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [showGenerateForm, setShowGenerateForm] = 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 = () => { const handleLicenseGenerated = () => {
// Refresh the data after license is generated // Refresh the data after license is generated
refreshData(); refreshData();
@@ -158,6 +174,48 @@ export default function GenerateLicenseKeysTable({
: t("licenseTierPersonal"); : 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", accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"), friendlyName: t("licenseTableValidUntil"),
@@ -198,7 +256,7 @@ export default function GenerateLicenseKeysTable({
}} }}
/> />
<GenerateLicenseKeyForm <NewPricingLicenseForm
open={showGenerateForm} open={showGenerateForm}
setOpen={setShowGenerateForm} setOpen={setShowGenerateForm}
orgId={orgId} orgId={orgId}

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> </Link>
</p> </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"> <div className="py-6">
<p className="mb-3 text-center"> <p className="mb-3 text-center">
{t("supportKeyOptions")} {t("supportKeyOptions")}

View File

@@ -1,6 +1,8 @@
type CleanRedirectOptions = { type CleanRedirectOptions = {
fallback?: string; fallback?: string;
maxRedirectDepth?: number; maxRedirectDepth?: number;
/** When true, preserve all query params on the path (for internal redirects). Default false. */
allowAllQueryParams?: boolean;
}; };
const ALLOWED_QUERY_PARAMS = new Set([ const ALLOWED_QUERY_PARAMS = new Set([
@@ -16,14 +18,18 @@ export function cleanRedirect(
input: string, input: string,
options: CleanRedirectOptions = {} options: CleanRedirectOptions = {}
): string { ): string {
const { fallback = "/", maxRedirectDepth = 2 } = options; const {
fallback = "/",
maxRedirectDepth = 2,
allowAllQueryParams = false
} = options;
if (!input || typeof input !== "string") { if (!input || typeof input !== "string") {
return fallback; return fallback;
} }
try { try {
return sanitizeUrl(input, fallback, maxRedirectDepth); return sanitizeUrl(input, fallback, maxRedirectDepth, allowAllQueryParams);
} catch { } catch {
return fallback; return fallback;
} }
@@ -32,7 +38,8 @@ export function cleanRedirect(
function sanitizeUrl( function sanitizeUrl(
input: string, input: string,
fallback: string, fallback: string,
remainingRedirectDepth: number remainingRedirectDepth: number,
allowAllQueryParams: boolean = false
): string { ): string {
if ( if (
input.startsWith("javascript:") || input.startsWith("javascript:") ||
@@ -56,7 +63,7 @@ function sanitizeUrl(
const cleanParams = new URLSearchParams(); const cleanParams = new URLSearchParams();
for (const [key, value] of url.searchParams.entries()) { for (const [key, value] of url.searchParams.entries()) {
if (!ALLOWED_QUERY_PARAMS.has(key)) { if (!allowAllQueryParams && !ALLOWED_QUERY_PARAMS.has(key)) {
continue; continue;
} }
@@ -68,7 +75,8 @@ function sanitizeUrl(
const cleanedRedirect = sanitizeUrl( const cleanedRedirect = sanitizeUrl(
value, value,
"", "",
remainingRedirectDepth - 1 remainingRedirectDepth - 1,
allowAllQueryParams
); );
if (cleanedRedirect) { if (cleanedRedirect) {

View File

@@ -0,0 +1,51 @@
import { cleanRedirect } from "@app/lib/cleanRedirect";
export const INTERNAL_REDIRECT_KEY = "internal_redirect";
/**
* Consumes the internal_redirect value from localStorage if present and valid
* (within TTL). Removes it from storage. Returns the path segment (with leading
* slash) to append to an orgId, or null if none/expired/invalid.
*/
export function consumeInternalRedirectPath(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(INTERNAL_REDIRECT_KEY);
if (raw == null || raw === "") return null;
window.localStorage.removeItem(INTERNAL_REDIRECT_KEY);
const { path: storedPath, expiresAt } = JSON.parse(raw) as {
path?: string;
expiresAt?: number;
};
if (
typeof storedPath !== "string" ||
storedPath === "" ||
typeof expiresAt !== "number" ||
Date.now() > expiresAt
) {
return null;
}
const cleaned = cleanRedirect(storedPath, {
fallback: "",
allowAllQueryParams: true
});
if (!cleaned) return null;
return cleaned.startsWith("/") ? cleaned : `/${cleaned}`;
} catch {
return null;
}
}
/**
* Returns the full redirect target for an org: either `/${orgId}` or
* `/${orgId}${path}` if a valid internal_redirect was stored. Consumes the
* stored value.
*/
export function getInternalRedirectTarget(orgId: string): string {
const path = consumeInternalRedirectPath();
return path ? `/${orgId}${path}` : `/${orgId}`;
}

View File

@@ -33,8 +33,11 @@ export function SubscriptionStatusProvider({
}; };
const isActive = () => { const isActive = () => {
if (subscriptionStatus?.subscription?.status === "active") { if (subscriptionStatus?.subscriptions) {
return true; // Check if any subscription is active
return subscriptionStatus.subscriptions.some(
(sub) => sub.subscription?.status === "active"
);
} }
return false; return false;
}; };
@@ -42,15 +45,20 @@ export function SubscriptionStatusProvider({
const getTier = () => { const getTier = () => {
const tierPriceSet = getTierPriceSet(env, sandbox_mode); const tierPriceSet = getTierPriceSet(env, sandbox_mode);
if (subscriptionStatus?.items && subscriptionStatus.items.length > 0) { if (subscriptionStatus?.subscriptions) {
// Iterate through tiers in order (earlier keys are higher tiers) // Iterate through all subscriptions
for (const [tierId, priceId] of Object.entries(tierPriceSet)) { for (const { subscription, items } of subscriptionStatus.subscriptions) {
// Check if any subscription item matches this tier's price ID if (items && items.length > 0) {
const matchingItem = subscriptionStatus.items.find( // Iterate through tiers in order (earlier keys are higher tiers)
(item) => item.priceId === priceId for (const [tierId, priceId] of Object.entries(tierPriceSet)) {
); // Check if any subscription item matches this tier's price ID
if (matchingItem) { const matchingItem = items.find(
return tierId; (item) => item.priceId === priceId
);
if (matchingItem) {
return tierId;
}
}
} }
} }
} }