286 lines
13 KiB
TypeScript
286 lines
13 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;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
const navItems: NavItem[] = [
|
|
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
|
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
|
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
|
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
|
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
|
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
|
{ 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();
|
|
|
|
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
|
|
|
|
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">
|
|
<ul className="space-y-1">
|
|
{visibleItems.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>
|
|
</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>
|
|
);
|
|
}
|