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>
);
}