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