feat(web): licence banner, settings page and feature gating

This commit is contained in:
2026-07-24 15:25:29 +01:00
parent ee4dff09c9
commit 8626898e5e
8 changed files with 217 additions and 4 deletions
+5 -1
View File
@@ -1,4 +1,5 @@
import { AuthProvider } from "@/components/AuthProvider";
import { LicenseBanner } from "@/components/LicenseBanner";
import { Sidebar } from "@/components/Sidebar";
export default function AppLayout({
@@ -10,7 +11,10 @@ export default function AppLayout({
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
<main className="flex-1 overflow-y-auto">
<LicenseBanner />
{children}
</main>
</div>
</AuthProvider>
);
+17 -2
View File
@@ -7,6 +7,7 @@ import Link from "next/link";
import { api, ServerStatus, GenerateKeyOptions, PackageUpdate, Inventory } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
import { useLicense } from "@/lib/useLicense";
function statusVariant(status: ServerStatus) {
switch (status) {
@@ -307,6 +308,8 @@ export default function ServerDetailPage() {
const [updateSuccess, setUpdateSuccess] = useState(false);
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
const [applySuccess, setApplySuccess] = useState(false);
const { hasFeature } = useLicense();
const consoleAllowed = hasFeature("console");
const {
data: server,
@@ -396,9 +399,21 @@ export default function ServerDetailPage() {
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
</div>
<div className="flex gap-2">
{/* Rendered disabled rather than hidden when the licence does not
include the console: a customer cannot buy what they cannot see,
and a feature that vanishes reads as a bug. */}
{server.console_protocols?.map((p) => (
<Link key={p} href={`/servers/${serverId}/console?protocol=${p}`}>
<Button variant="secondary">
<Link
key={p}
href={consoleAllowed ? `/servers/${serverId}/console?protocol=${p}` : "#"}
aria-disabled={!consoleAllowed}
title={consoleAllowed ? undefined : "Upgrade to use the browser console"}
onClick={(e) => {
if (!consoleAllowed) e.preventDefault();
}}
className={consoleAllowed ? undefined : "pointer-events-none opacity-50"}
>
<Button variant="secondary" disabled={!consoleAllowed}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { licence } from "@/lib/api";
import { useLicense } from "@/lib/useLicense";
function cap(n: number) {
return n === -1 ? "Unlimited" : String(n);
}
export default function LicensePage() {
const { license } = useLicense();
const queryClient = useQueryClient();
const [blob, setBlob] = useState("");
const [error, setError] = useState("");
const save = useMutation({
mutationFn: () => licence.put(blob.trim()),
onSuccess: () => {
setBlob("");
setError("");
queryClient.invalidateQueries({ queryKey: ["license"] });
},
onError: (e: Error) => setError(e.message),
});
if (!license) return null;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-text-primary">Licence</h1>
<section className="rounded border border-border p-4">
<p className="text-sm text-text-secondary">
State: <b>{license.state}</b>
{license.tier ? <> · Tier: <b>{license.tier}</b></> : null}
{license.expires_at ? <> · Expires {new Date(license.expires_at).toLocaleDateString()}</> : null}
</p>
<p className="mt-2 text-xs text-text-tertiary">
Instance ID quote this when buying or activating a licence
</p>
<div className="mt-1 flex items-center gap-2">
<code className="text-sm">{license.instance_id}</code>
<button
type="button"
className="text-xs underline"
onClick={() => navigator.clipboard.writeText(license.instance_id)}
>
Copy
</button>
</div>
</section>
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Usage</h2>
<ul className="mt-2 space-y-1 text-sm text-text-secondary">
<li>Servers: {license.usage.servers} of {cap(license.limits.max_servers)}</li>
<li>Secret groups: {license.usage.secret_groups} of {cap(license.limits.max_secret_groups)}</li>
<li>Notification channels: {license.usage.channels} of {cap(license.limits.max_channels)}</li>
<li>Browser console: {license.features.console ? "Included" : "Not included"}</li>
<li>Single sign-on: {license.features.oidc ? "Included" : "Not included"}</li>
</ul>
</section>
<section className="rounded border border-border p-4">
<h2 className="font-semibold text-text-primary">Add or replace a licence</h2>
<textarea
className="mt-2 h-32 w-full rounded border border-border bg-transparent p-2 font-mono text-xs"
placeholder="Paste your licence key"
value={blob}
onChange={(e) => setBlob(e.target.value)}
/>
<input
type="file"
accept=".lic,.txt"
className="mt-2 block text-xs"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f) setBlob((await f.text()).trim());
}}
/>
{error ? <p className="mt-2 text-sm text-red-400">{error}</p> : null}
<button
type="button"
className="mt-3 rounded bg-accent px-3 py-1.5 text-sm"
disabled={!blob.trim() || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? "Checking…" : "Save licence"}
</button>
</section>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { useLicense } from "@/lib/useLicense";
export function LicenseBanner() {
const { license } = useLicense();
if (!license) return null;
if (license.state === "expired") {
const when = license.expires_at ? new Date(license.expires_at).toLocaleDateString() : "recently";
return (
<div className="bg-amber-900/40 px-4 py-2 text-sm text-amber-100">
Your Vantage licence expired on {when}. Your servers and monitors are still running,
but changes are disabled until it is renewed.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
</div>
);
}
if (license.state === "invalid") {
return (
<div className="bg-red-900/40 px-4 py-2 text-sm text-red-100">
This instance has no valid licence. Changes are disabled.{" "}
<Link href="/settings/license" className="underline">Add a licence</Link>
</div>
);
}
if (typeof license.days_remaining === "number" && license.days_remaining <= 14) {
return (
<div className="bg-amber-900/25 px-4 py-2 text-sm text-amber-100">
Your licence expires in {license.days_remaining} day
{license.days_remaining === 1 ? "" : "s"}.
</div>
);
}
return null;
}
+13
View File
@@ -75,6 +75,18 @@ function AuditIcon() {
);
}
function LicenceIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 0 1-1.043 3.296 3.745 3.745 0 0 1-3.296 1.043A3.745 3.745 0 0 1 12 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 0 1-3.296-1.043 3.745 3.745 0 0 1-1.043-3.296A3.745 3.745 0 0 1 3 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 0 1 1.043-3.296 3.746 3.746 0 0 1 3.296-1.043A3.746 3.746 0 0 1 12 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 0 1 3.296 1.043 3.746 3.746 0 0 1 1.043 3.296A3.745 3.745 0 0 1 21 12Z"
/>
</svg>
);
}
function SettingsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -129,6 +141,7 @@ const navItems: NavItem[] = [
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/instance", label: "Instance", icon: <InstanceIcon />, adminOnly: true },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
+25
View File
@@ -791,3 +791,28 @@ export const api = {
return `/api/runs/${runId}/servers/${serverId}/logs/stream`;
},
};
export type LicenseState = "valid" | "expired" | "invalid";
export interface LicenseInfo {
instance_id: string;
state: LicenseState;
reason?: string;
tier?: string;
expires_at?: string;
days_remaining?: number;
limits: { max_servers: number; max_secret_groups: number; max_channels: number };
features: Record<string, boolean>;
usage: { servers: number; secret_groups: number; channels: number };
source: string;
}
// `request` already prefixes /api, so these paths do not repeat it.
export const licence = {
get(): Promise<LicenseInfo> {
return request<LicenseInfo>("/license");
},
put(blob: string): Promise<{ state: LicenseState; tier: string; expires_at?: string }> {
return request("/license", { method: "POST", body: JSON.stringify({ blob }) });
},
};
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { licence, type LicenseInfo } from "@/lib/api";
export function useLicense() {
const { data, isLoading } = useQuery<LicenseInfo>({
queryKey: ["license"],
queryFn: licence.get,
staleTime: 60_000,
});
return {
license: data,
isLoading,
isActive: data?.state === "valid",
// Features render disabled rather than hidden, so treat "unknown while
// loading" as available to avoid a flash of disabled controls.
hasFeature: (name: string) => (data ? Boolean(data.features?.[name]) : true),
};
}
File diff suppressed because one or more lines are too long