License machines

This commit is contained in:
Owen
2026-10-20 15:29:37 -04:00
parent 77c0c96823
commit d80c1ff27b
10 changed files with 142 additions and 10 deletions
+4
View File
@@ -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.}}",
@@ -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
}
]}
/>
+3
View File
@@ -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 {
+28 -9
View File
@@ -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;
}
@@ -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`
}),
{
@@ -41,6 +41,7 @@ export interface LicenseKey {
type: string;
quantity: number;
quantity_2: number;
quantity_3: number;
isValid: boolean;
updatedAt: string;
createdAt: string;
+2
View File
@@ -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;
+47
View File
@@ -536,6 +536,53 @@ export default function LicensePage() {
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageMachines")}
</div>
<div className="text-2xl">
{t("licenseMachinesUsed", {
count: licenseStatus?.usedMachines || 0
})}
</div>
{licenseStatus?.maxMachines ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseMachinesUsedMax", {
usedMachines:
licenseStatus.usedMachines ||
0,
maxMachines:
licenseStatus.maxMachines
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedMachines ||
0) /
licenseStatus.maxMachines) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedMachines ||
0) /
licenseStatus.maxMachines) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoMachineLimit")}
</div>
)}
</div>
</div>
</SettingsSection>
<SettingsSection>
@@ -255,6 +255,27 @@ export default function GenerateLicenseKeysTable({
return sites === -1 ? "∞" : sites;
}
},
{
accessorKey: "machines",
friendlyName: t("machineClients"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("machineClients")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const machines = row.original.machines;
return machines === -1 ? "∞" : machines;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),
+24
View File
@@ -160,6 +160,30 @@ export function LicenseKeysDataTable({
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "quantity_3",
friendlyName: t("machineClients"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("machineClients")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const quantity = row.original.quantity_3;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),