diff --git a/messages/en-US.json b/messages/en-US.json index dca710fb1..159714185 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -604,6 +604,10 @@ "licenseNoUserLimit": "There is no limit on the number of users for this host.", "licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used", "licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}}", + "licenseUsageMachines": "Machine Clients", + "licenseNoMachineLimit": "There is no limit on the number of machine clients for this host.", + "licenseMachinesUsedMax": "{usedMachines} of {maxMachines} machine clients used", + "licenseMachinesUsed": "{count, plural, =0 {# machine clients} one {# machine client} other {# machine clients}}", "licenseUnlimited": "Unlimited", "licenseTierLabel": "Tier", "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx index dc3890abe..ec9937691 100644 --- a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -20,6 +20,7 @@ type EnterpriseEditionKeyGeneratedProps = { personalUseOnly: boolean; users: number; sites: number; + machines: number; modifySubscriptionLink?: string; }; @@ -28,6 +29,7 @@ export const EnterpriseEditionKeyGenerated = ({ personalUseOnly, users, sites, + machines, modifySubscriptionLink }: EnterpriseEditionKeyGeneratedProps) => { const previewText = personalUseOnly @@ -69,6 +71,10 @@ export const EnterpriseEditionKeyGenerated = ({ { label: "Licensed sites", value: sites + }, + { + label: "Licensed machine clients", + value: machines } ]} /> diff --git a/server/license/license.ts b/server/license/license.ts index d39842f44..a6e58b5a8 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -16,6 +16,8 @@ export type LicenseStatus = { usedSites?: number; maxUsers?: number; usedUsers?: number; + maxMachines?: number; + usedMachines?: number; }; export type LicenseKeyCache = { @@ -28,6 +30,7 @@ export type LicenseKeyCache = { terminateAt?: Date; quantity?: number; quantity_2?: number; + quantity_3?: number; }; export class License { diff --git a/server/private/license/license.ts b/server/private/license/license.ts index 6614e1a6f..00c5b1fcb 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -11,12 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, HostMeta, primaryDb, sites, users } from "@server/db"; +import { clients, db, HostMeta, primaryDb, sites, users } from "@server/db"; import { hostMeta, licenseKey } from "@server/db"; import logger from "@server/logger"; import { createLocalCache } from "@server/lib/createLocalCache"; import { validateJWT } from "./licenseJwt"; -import { count, eq } from "drizzle-orm"; +import { count, eq, isNull } from "drizzle-orm"; import moment from "moment"; import { encrypt, decrypt } from "@server/lib/crypto"; import { @@ -77,6 +77,7 @@ type TokenPayload = { tier: LicenseKeyTier; quantity: number; quantity_2: number; + quantity_3: number; terminateAt: string; // ISO iat: number; // Issued at }; @@ -190,9 +191,13 @@ LQIDAQAB }; } - // Count used sites and users for license comparison + // Count used sites, users, and machine clients for license comparison const [siteCountRes] = await db.select({ value: count() }).from(sites); const [userCountRes] = await db.select({ value: count() }).from(users); + const [machineCountRes] = await db + .select({ value: count() }) + .from(clients) + .where(isNull(clients.userId)); const keyIdsRes = await db .select({ licenseKeyId: licenseKey.licenseKeyId }) .from(licenseKey); @@ -202,7 +207,8 @@ LQIDAQAB isHostLicensed: true, isLicenseValid: false, usedSites: siteCountRes?.value ?? 0, - usedUsers: userCountRes?.value ?? 0 + usedUsers: userCountRes?.value ?? 0, + usedMachines: machineCountRes?.value ?? 0 }; this.checkInProgress = true; @@ -216,6 +222,7 @@ LQIDAQAB const res = this.statusCache.get("status") as LicenseStatus; res.usedSites = status.usedSites; res.usedUsers = status.usedUsers; + res.usedMachines = status.usedMachines; return res; } logger.debug("Checking license status..."); @@ -273,7 +280,8 @@ LQIDAQAB iat: new Date(payload.iat * 1000), terminateAt: new Date(payload.terminateAt), quantity: payload.quantity, - quantity_2: payload.quantity_2 + quantity_2: payload.quantity_2, + quantity_3: payload.quantity_3 }); if (payload.type === "host") { @@ -391,6 +399,7 @@ LQIDAQAB cached.terminateAt = new Date(payload.terminateAt); cached.quantity = payload.quantity; cached.quantity_2 = payload.quantity_2; + cached.quantity_3 = payload.quantity_3; // Encrypt the updated token before storing const encryptedKey = encrypt( @@ -445,7 +454,8 @@ LQIDAQAB } } - // Compute host status: quantity = users, quantity_2 = sites + // Compute host status: quantity = users, quantity_2 = sites, + // quantity_3 = machine clients // When multiple host keys are active, prefer a valid key over an // invalid one, and among equally-valid keys prefer the highest tier. let selectedHostKey: LicenseKeyCache | undefined; @@ -468,7 +478,10 @@ LQIDAQAB continue; } - // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) + // Only consider quantity if defined and >= 0 (quantity = users, + // quantity_2 = sites, quantity_3 = machine clients). Keys issued + // before machine limits existed omit quantity_3 and so impose no + // machine limit. if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) { status.maxSites = (status.maxSites ?? 0) + cached.quantity_2; @@ -476,6 +489,10 @@ LQIDAQAB if (cached.quantity !== undefined && cached.quantity >= 0) { status.maxUsers = (status.maxUsers ?? 0) + cached.quantity; } + if (cached.quantity_3 !== undefined && cached.quantity_3 >= 0) { + status.maxMachines = + (status.maxMachines ?? 0) + cached.quantity_3; + } } if (selectedHostKey) { @@ -483,12 +500,14 @@ LQIDAQAB status.tier = selectedHostKey.tier; } - // Invalidate license if over user or site limits + // Invalidate license if over user, site, or machine client limits if ( (status.maxSites !== undefined && (status.usedSites ?? 0) > status.maxSites) || (status.maxUsers !== undefined && - (status.usedUsers ?? 0) > status.maxUsers) + (status.usedUsers ?? 0) > status.maxUsers) || + (status.maxMachines !== undefined && + (status.usedMachines ?? 0) > status.maxMachines) ) { status.isLicenseValid = false; } diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 7cf21577a..3783c0d71 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -222,15 +222,18 @@ export async function handleSubscriptionCreated( let numUsers: number; let numSites: number; + let numMachines: number; let tier = "enterprise"; if (subscriptionPriceId === priceSet[LicenseId.TIER1]) { numUsers = 25; numSites = 25; + numMachines = 25; tier = "tier1"; } else if (subscriptionPriceId === priceSet[LicenseId.TIER2]) { numUsers = 50; numSites = 100; + numMachines = 50; tier = "tier2"; } else { logger.error( @@ -240,7 +243,7 @@ export async function handleSubscriptionCreated( } logger.debug( - `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` + `License type determined: ${numUsers} users, ${numSites} sites, ${numMachines} machines for subscription ${subscription.id}` ); // Grace period of 5 days added on top of the current billing @@ -266,6 +269,7 @@ export async function handleSubscriptionCreated( paidFor: true, users: numUsers, sites: numSites, + machines: numMachines, tier: tier, expiresAt: expiresAt }) @@ -293,6 +297,7 @@ export async function handleSubscriptionCreated( personalUseOnly: false, users: numUsers, sites: numSites, + machines: numMachines, modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing` }), { diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index a97535d8a..a4e09a2f9 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -41,6 +41,7 @@ export interface LicenseKey { type: string; quantity: number; quantity_2: number; + quantity_3: number; isValid: boolean; updatedAt: string; createdAt: string; diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index d78f23326..d71dfb69a 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -8,6 +8,7 @@ export type GeneratedLicenseKey = { type: string; users: number; sites: number; + machines: number; }; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; @@ -22,6 +23,7 @@ export type NewLicenseKey = { type: string; quantity: number; quantity_2: number; + quantity_3: number; isValid: boolean; updatedAt: string; createdAt: string; diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index cdc564297..a0fcf1f12 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -536,6 +536,53 @@ export default function LicensePage() { )} +
+
+ {t("licenseUsageMachines")} +
+
+ {t("licenseMachinesUsed", { + count: licenseStatus?.usedMachines || 0 + })} +
+ {licenseStatus?.maxMachines ? ( +
+
+ + {t("licenseMachinesUsedMax", { + usedMachines: + licenseStatus.usedMachines || + 0, + maxMachines: + licenseStatus.maxMachines + })} + + + {Math.round( + ((licenseStatus.usedMachines || + 0) / + licenseStatus.maxMachines) * + 100 + )} + % + +
+ +
+ ) : ( +
+ {t("licenseNoMachineLimit")} +
+ )} +
diff --git a/src/components/GenerateLicenseKeysTable.tsx b/src/components/GenerateLicenseKeysTable.tsx index 300163e8a..43653b2bb 100644 --- a/src/components/GenerateLicenseKeysTable.tsx +++ b/src/components/GenerateLicenseKeysTable.tsx @@ -255,6 +255,27 @@ export default function GenerateLicenseKeysTable({ return sites === -1 ? "∞" : sites; } }, + { + accessorKey: "machines", + friendlyName: t("machineClients"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const machines = row.original.machines; + return machines === -1 ? "∞" : machines; + } + }, { accessorKey: "terminateAt", friendlyName: t("licenseTableValidUntil"), diff --git a/src/components/LicenseKeysDataTable.tsx b/src/components/LicenseKeysDataTable.tsx index 939686503..9a2fdc37c 100644 --- a/src/components/LicenseKeysDataTable.tsx +++ b/src/components/LicenseKeysDataTable.tsx @@ -160,6 +160,30 @@ export function LicenseKeysDataTable({ return quantity < 0 ? t("licenseUnlimited") : quantity; } }, + { + accessorKey: "quantity_3", + friendlyName: t("machineClients"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const quantity = row.original.quantity_3; + if (quantity === undefined) { + return "-"; + } + return quantity < 0 ? t("licenseUnlimited") : quantity; + } + }, { accessorKey: "terminateAt", friendlyName: t("licenseTableValidUntil"),