feat(web): licence banner, settings page and feature gating
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 }) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user