From 9df89e2db402febb8134f20eb65d1b5eae439ac5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Thu, 6 Aug 2026 15:10:26 +0100 Subject: [PATCH] fix: surface vuln_scanning across licence, staff and pricing UI The catalogue row alone was not enough; the feature was invisible in three places and mislabelled in a fourth. PlanConfigurator rendered every key that was not "console" as "Single sign-on", so the staff checkbox granting vulnerability scanning was labelled single sign-on. Feature wording was duplicated between the staff configurator and the purchase form and the copies had drifted, so it now lives in adminsite/lib/features.ts and both read from it. The customer licence panel showed raw keys; it now labels them. Pricing gains a comparison row. The add-on block with a monthly price is deliberately NOT added: that is a pricing decision, and the Paddle price IDs for the new catalogue rows have to be pasted in before it can be sold anyway. --- admin/internal/models/catalogue.go | 3 ++- .../app/(customer)/instances/[id]/page.tsx | 7 ++++- .../app/(customer)/purchase/PurchaseForm.tsx | 19 ++++--------- adminsite/components/PlanConfigurator.tsx | 3 ++- adminsite/lib/features.ts | 27 +++++++++++++++++++ site/app/pricing/page.tsx | 3 ++- 6 files changed, 44 insertions(+), 18 deletions(-) create mode 100644 adminsite/lib/features.ts diff --git a/admin/internal/models/catalogue.go b/admin/internal/models/catalogue.go index 56c6a45..6affabd 100644 --- a/admin/internal/models/catalogue.go +++ b/admin/internal/models/catalogue.go @@ -67,7 +67,8 @@ func (r CatalogueRow) Priced(env string) bool { return false } -// SeedCatalogue inserts the sixteen rows the four PAID plans need. +// SeedCatalogue inserts the twenty rows the four PAID plans need: a base, a +// server limit, and one row per feature key. // // The two Free plans get no rows at all, and that absence is what keeps Free // outside Paddle: with nothing to price, no checkout can be built for it. Do not diff --git a/adminsite/app/(customer)/instances/[id]/page.tsx b/adminsite/app/(customer)/instances/[id]/page.tsx index 38d3cd6..7230201 100644 --- a/adminsite/app/(customer)/instances/[id]/page.tsx +++ b/adminsite/app/(customer)/instances/[id]/page.tsx @@ -12,6 +12,7 @@ import { StatePill } from "@/components/StatePill"; import { PageFrame, RailCard, RailFacts } from "@/components/PageFrame"; import { PageHeader } from "@/components/PageHeader"; import { formatDate, licenceState, limitLabel } from "@/lib/format"; +import { featureLabel } from "@/lib/features"; export default function InstancePage() { const id = String(useParams().id); @@ -104,7 +105,11 @@ export default function InstancePage() { }, { label: "Features", - value: lic.features.join(", ") || "none", + // Labelled, not raw keys: this is + // the customer's own licence, and + // "vuln_scanning" is not a name + // anyone bought. + value: lic.features.map(featureLabel).join(", ") || "none", }, ]} /> diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx index 899ed9a..3925b22 100644 --- a/adminsite/app/(customer)/purchase/PurchaseForm.tsx +++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx @@ -6,25 +6,16 @@ import Link from "next/link"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ApiError, api, lineItemsFor, type CatalogueRow, type CheckoutOptions, type Deployment, type Plan, type Term, type Tier } from "@/lib/api"; import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle"; +import { featureDesc, featureLabel } from "@/lib/features"; /* Tiers in the order a customer reads them, cheapest first. */ const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/* Human labels for feature keys. The catalogue names them by key; this is the - * one place the customer-facing wording lives. */ -const FEATURE_LABEL: Record = { - console: "Browser console", - oidc: "Single sign-on", -}; -const FEATURE_DESC: Record = { - console: "In-browser SSH, RDP and VNC sessions", - oidc: "OIDC sign-in for your whole team", -}; -function featureLabel(key: string) { - return FEATURE_LABEL[key] ?? key; -} +/* Feature wording lives in lib/features.ts, shared with the staff + * configurator. It was duplicated here and there, and the two copies had + * already drifted. */ interface Choice { tier: Tier; @@ -294,7 +285,7 @@ export function PurchaseForm() { {featureKeys.map((key) => { const st = featureStateFor(plan, rows, options.env, choice.term, key); return ( - + {st === "included" ? ( Included ) : st === "absent" ? ( diff --git a/adminsite/components/PlanConfigurator.tsx b/adminsite/components/PlanConfigurator.tsx index c567747..c008dfa 100644 --- a/adminsite/components/PlanConfigurator.tsx +++ b/adminsite/components/PlanConfigurator.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api"; +import { featureLabel } from "@/lib/features"; export interface PlanChoice { tier: Tier; @@ -159,7 +160,7 @@ export default function PlanConfigurator({ }) } /> - {key === "console" ? "Browser console" : "Single sign-on"} + {featureLabel(key)} {priced ? "paid add-on" : "included"} diff --git a/adminsite/lib/features.ts b/adminsite/lib/features.ts new file mode 100644 index 0000000..7ee06a2 --- /dev/null +++ b/adminsite/lib/features.ts @@ -0,0 +1,27 @@ +/* Human wording for licence feature keys. + * + * One place, because there were two and they disagreed: the staff configurator + * rendered every key that was not "console" as "Single sign-on", so adding a + * third feature silently mislabelled the checkbox that grants it. A map with a + * fallback degrades to the raw key, which is ugly but never wrong. + * + * Keys must match shared/license/license.go. */ +export const FEATURE_LABEL: Record = { + console: "Browser console", + oidc: "Single sign-on", + vuln_scanning: "Vulnerability scanning", +}; + +export const FEATURE_DESC: Record = { + console: "In-browser SSH, RDP and VNC sessions", + oidc: "OIDC sign-in for your whole team", + vuln_scanning: "Package inventory matched against distribution security advisories", +}; + +export function featureLabel(key: string): string { + return FEATURE_LABEL[key] ?? key; +} + +export function featureDesc(key: string): string { + return FEATURE_DESC[key] ?? ""; +} diff --git a/site/app/pricing/page.tsx b/site/app/pricing/page.tsx index 97909a4..29fffd6 100644 --- a/site/app/pricing/page.tsx +++ b/site/app/pricing/page.tsx @@ -21,6 +21,7 @@ const COMPARISON: [string, string, string, string][] = [ ["Audit history", "30 days", "365 days", "Forever"], ["Browser console", "Not Available", "Add-on", "Add-on"], ["Single sign-on", "Not Available", "Add-on", "Add-on"], + ["Vulnerability scanning", "Not Available", "Add-on", "Add-on"], ["Support", "Community", "Email, 24/5", "Email and phone, 24/7"], ["Cloud term", "Annual, £0", "Monthly or annual", "Monthly or annual"], ["Self-hosted term", "Annual, £0", "Annual", "Annual"], @@ -79,7 +80,7 @@ export default function PricingPage() {
  • 3 servers included, add as many as you like
  • Unlimited monitors, secrets and channels
  • 365 days of audit history
  • -
  • Browser console and single sign-on as add-ons
  • +
  • Browser console, single sign-on and vulnerability scanning as add-ons
  • Email support, 24/5