/api-keys shares a raw string prefix with /api, and the proxies in front of this app do not all match by path segment. Nginx Proxy Manager routes /api straight to the Go server with a prefix location, so /api-keys never reached Next at all — it reached a control plane with no such route and came back as a JSON 404. Traefik's PathPrefix has the same shape of matcher, which puts the Helm ingress at risk whenever ingress.api.enabled is on. The page is /tokens now, which cannot collide with anything, and which matches the /api/tokens the REST API already publishes. The sidebar still says API Keys — the label is for the reader, the path is for the router. A permanent redirect covers anyone who bookmarked the old path today. Fixing the proxy config instead would have left the trap set for the next deployment, and for whatever sits in front of it.
361 lines
17 KiB
TypeScript
361 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useEffect, useRef } from "react";
|
|
import { clsx } from "clsx";
|
|
import { useAuth } from "@/components/AuthProvider";
|
|
import { auth } from "@/lib/api";
|
|
import { Logo } from "./Logo";
|
|
|
|
interface NavItem {
|
|
href: string;
|
|
label: string;
|
|
icon: React.ReactNode;
|
|
/** Restricted to owner/admin the roles the backing API requires. */
|
|
adminOnly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* A labelled run of nav items. Grouping is by what the operator is doing, not
|
|
* by which API serves the page: credentials sit together under Access whether
|
|
* they are SSH keys, vault secrets or API keys.
|
|
*/
|
|
interface NavGroup {
|
|
label: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
function ServerIcon() {
|
|
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="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function KeyIcon() {
|
|
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="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SecretIcon() {
|
|
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="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function WorkflowIcon() {
|
|
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 3.75H6.912a2.25 2.25 0 00-2.15 1.588L2.35 13.177a2.25 2.25 0 00-.1.661V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 00-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 012.012 1.244l.256.512a2.25 2.25 0 002.012 1.244h3.22a2.25 2.25 0 002.012-1.244l.256-.512a2.25 2.25 0 012.012-1.244h3.86M12 3v8.25m0 0l-3-3m3 3l3-3"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AuditIcon() {
|
|
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 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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}>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
|
/>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MonitorIcon() {
|
|
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="M3 12h4l2 6 4-14 2 8h6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function StepsIcon() {
|
|
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="M6 6.75A.75.75 0 016.75 6h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 6.75zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 12zm0 5.25a.75.75 0 01.75-.75h10.5a.75.75 0 010 1.5H6.75A.75.75 0 016 17.25zM3 6.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM3 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 5.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ShieldIcon() {
|
|
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="M11.998 2.25a.75.75 0 01.298.062l7.5 3.214a.75.75 0 01.454.69v5.034c0 4.63-2.94 8.75-7.5 10.25a.75.75 0 01-.5 0c-4.56-1.5-7.5-5.62-7.5-10.25V6.216a.75.75 0 01.454-.69l7.5-3.214a.75.75 0 01.294-.062zM12 8.25v3.75m0 3h.008v.008H12v-.008z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function WorkloadIcon() {
|
|
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="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TokenIcon() {
|
|
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="M14.25 9.75L16.5 12l-2.25 2.25m-4.5 0L7.5 12l2.25-2.25M6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
const navGroups: NavGroup[] = [
|
|
{
|
|
label: "Fleet",
|
|
items: [
|
|
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
|
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
|
|
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
|
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
|
|
],
|
|
},
|
|
{
|
|
label: "Access",
|
|
items: [
|
|
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
|
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
|
// Not adminOnly: the API lets any member mint and revoke their own
|
|
// keys, capped at their own role, so gating the page would hide a
|
|
// capability they have.
|
|
{ href: "/tokens", label: "API Keys", icon: <TokenIcon /> },
|
|
],
|
|
},
|
|
{
|
|
label: "Automation",
|
|
items: [
|
|
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
|
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
|
],
|
|
},
|
|
{
|
|
label: "Instance",
|
|
items: [
|
|
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
|
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
|
|
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
|
|
],
|
|
},
|
|
];
|
|
|
|
/** Shared by the permanent aside and the offcanvas drawer one copy of the nav. */
|
|
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
|
|
const pathname = usePathname();
|
|
const { user, instance, isAdmin } = useAuth();
|
|
|
|
// A group whose every item is admin-only disappears entirely for a member,
|
|
// heading and rule included — an empty labelled section reads as something
|
|
// that failed to load.
|
|
const visibleGroups = navGroups
|
|
.map((group) => ({ ...group, items: group.items.filter((item) => !item.adminOnly || isAdmin) }))
|
|
.filter((group) => group.items.length > 0);
|
|
|
|
const visibleItems = visibleGroups.flatMap((group) => group.items);
|
|
|
|
const activeHref = visibleItems.reduce<string | null>((best, item) => {
|
|
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
|
|
if (!matches) return best;
|
|
return best === null || item.href.length > best.length ? item.href : best;
|
|
}, null);
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await auth.logout();
|
|
} catch {}
|
|
window.location.href = "/login";
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-5">
|
|
<Logo className="h-8 w-8 text-logo" />
|
|
<div className="min-w-0">
|
|
<span className="block text-base font-extrabold leading-tight tracking-[-0.035em] text-text-primary">Vantage</span>
|
|
{instance && <span className="block truncate font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{instance.name}</span>}
|
|
</div>
|
|
</div>
|
|
|
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
|
{visibleGroups.map((group, groupIndex) => (
|
|
<div
|
|
key={group.label}
|
|
// The heading terminates the group above it, so the rule
|
|
// goes on top and the first group needs none.
|
|
className={clsx(groupIndex > 0 && "mt-4 border-t border-border pt-4")}
|
|
>
|
|
<p className="px-3 pb-1.5 font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">{group.label}</p>
|
|
<ul className="space-y-1">
|
|
{group.items.map((item) => {
|
|
const isActive = activeHref === item.href;
|
|
return (
|
|
<li key={item.href}>
|
|
<Link
|
|
href={item.href}
|
|
onClick={onNavigate}
|
|
// The active marker is an accent bar, the same device
|
|
// site/ uses to mark the chosen plan. A filled pill
|
|
// reads as a button you can press again.
|
|
className={clsx(
|
|
"relative flex items-center gap-3 rounded px-3 py-2.5 text-sm transition-colors",
|
|
isActive
|
|
? "bg-surface-2 font-semibold text-text-primary before:absolute before:inset-y-1 before:left-0 before:w-[2px] before:rounded-full before:bg-accent before:content-['']"
|
|
: "font-medium text-text-secondary hover:bg-surface-2 hover:text-text-primary",
|
|
)}
|
|
>
|
|
{item.icon}
|
|
{item.label}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</nav>
|
|
|
|
<div className="shrink-0 border-t border-border px-4 py-3">
|
|
{user && (
|
|
<div className="mb-3">
|
|
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
|
|
<p className="truncate text-xs text-text-secondary">
|
|
{user.email}
|
|
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between">
|
|
<p className="font-mono text-[0.68rem] uppercase tracking-[0.1em] text-text-secondary">Vantage v1.0</p>
|
|
{user && (
|
|
<button type="button" onClick={handleLogout} className="text-xs text-text-secondary transition-colors hover:text-danger">
|
|
Logout
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** The permanent sidebar. Below lg the drawer takes over. */
|
|
export function Sidebar() {
|
|
return (
|
|
<aside className="hidden h-screen w-60 shrink-0 flex-col border-r border-border bg-surface lg:flex">
|
|
<SidebarContent />
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The offcanvas below lg. Always mounted so the slide runs in both directions;
|
|
* closed it is inert (invisible + pointer-events-none) rather than unmounted.
|
|
*/
|
|
export function SidebarDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
|
|
panelRef.current?.focus();
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
document.body.style.overflow = previousOverflow;
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
return (
|
|
<div className={clsx("fixed inset-0 z-50 lg:hidden", open ? "visible" : "invisible pointer-events-none")}>
|
|
<div aria-hidden="true" onClick={onClose} className={clsx("absolute inset-0 bg-black/60 transition-opacity duration-200", open ? "opacity-100" : "opacity-0")} />
|
|
<div
|
|
ref={panelRef}
|
|
id="app-sidebar-drawer"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Navigation"
|
|
tabIndex={-1}
|
|
className={clsx(
|
|
"absolute inset-y-0 left-0 flex w-72 max-w-[85%] flex-col border-r border-border bg-surface outline-none transition-transform duration-200 ease-out",
|
|
open ? "translate-x-0" : "-translate-x-full",
|
|
)}
|
|
>
|
|
<SidebarContent onNavigate={onClose} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|