Display the quantities again

This commit is contained in:
Owen
2026-09-22 09:39:17 -04:00
parent d134304159
commit 9de91e2f81
3 changed files with 201 additions and 65 deletions
+8
View File
@@ -590,6 +590,14 @@
"licensePurchaseSites": "Purchase Additional Sites",
"licenseSitesUsedMax": "{usedSites} of {maxSites} sites used",
"licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.",
"licenseUsage": "License Usage",
"licenseUsageDescription": "View the number of users and sites licensed for this host.",
"licenseUsageSites": "Sites",
"licenseUsageUsers": "Users",
"licenseNoUserLimit": "There is no limit on the number of users using an unlicensed host.",
"licenseUsersUsedMax": "{usedUsers} of {maxUsers} users used",
"licenseUsersUsed": "{count, plural, =0 {# users} one {# user} other {# users}} in system.",
"licenseUnlimited": "Unlimited",
"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.}}",
"licenseFee": "License fee",
"licensePriceSite": "Price per site",
+145 -65
View File
@@ -41,6 +41,7 @@ import {
SettingsSectionHeader,
SettingsSectionFooter
} from "@app/components/Settings";
import { Progress } from "@app/components/ui/progress";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
ArrowRight,
@@ -64,13 +65,6 @@ const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
const ENTERPRISE_PRICING_URL = "https://pangolin.net/pricing#Self-Hosted";
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
export default function LicensePage() {
const api = createApiClient(useEnvContext());
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
@@ -80,7 +74,6 @@ export default function LicensePage() {
useState<LicenseKeyCache | null>(null);
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<"license">("license");
@@ -128,12 +121,6 @@ export default function LicensePage() {
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "host");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
setHostLicense(null);
}
} catch (e) {
toast({
title: t("licenseErrorKeyLoad"),
@@ -399,62 +386,155 @@ export default function LicensePage() {
{/* </Alert> */}
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseUsage")}</SSTitle>
<SettingsSectionDescription>
{t("licenseUsageDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-6">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageSites")}
</div>
) : (
<div className="text-2xl">
{t("unlicensed")}
{t("licenseSitesUsed", {
count: licenseStatus?.usedSites || 0
})}
</div>
{licenseStatus?.maxSites ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseSitesUsedMax", {
usedSites:
licenseStatus.usedSites ||
0,
maxSites:
licenseStatus.maxSites
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoSiteLimit")}
</div>
)}
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseUsageUsers")}
</div>
<div className="text-2xl">
{t("licenseUsersUsed", {
count: licenseStatus?.usedUsers || 0
})}
</div>
{licenseStatus?.maxUsers ? (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{t("licenseUsersUsedMax", {
usedUsers:
licenseStatus.usedUsers ||
0,
maxUsers:
licenseStatus.maxUsers
})}
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedUsers ||
0) /
licenseStatus.maxUsers) *
100
}
className="h-5"
/>
</div>
) : (
<div className="text-sm text-muted-foreground">
{t("licenseNoUserLimit")}
</div>
)}
</div>
</div>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>{t("licenseHost")}</SSTitle>
<SettingsSectionDescription>
{t("licenseHostDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div>
</div>
) : (
<div className="text-2xl">
{t("unlicensed")}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("hostId")}
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("licenseKey")}
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
{t("licenseReckeckAll")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
+48
View File
@@ -112,6 +112,54 @@ export function LicenseKeysDataTable({
}
}
},
{
accessorKey: "quantity",
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 quantity = row.original.quantity;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "quantity_2",
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 quantity = row.original.quantity_2;
if (quantity === undefined) {
return "-";
}
return quantity < 0 ? t("licenseUnlimited") : quantity;
}
},
{
accessorKey: "terminateAt",
friendlyName: t("licenseTableValidUntil"),