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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user