mirror of
https://github.com/tale/headplane.git
synced 2026-09-04 03:05:41 +00:00
feat: refactor several components and clean up ui
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { Outlet, redirect } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/app";
|
||||
import Footer from "./footer";
|
||||
import Header from "./header";
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.onboarded &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
email: principal.profile.email,
|
||||
name: principal.profile.name,
|
||||
picture: principal.profile.picture,
|
||||
subject: principal.user.subject,
|
||||
username: principal.profile.username,
|
||||
}
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
access: {
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
ui: context.auth.can(principal, Capabilities.ui_access),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
},
|
||||
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
isDebug: context.config.debug,
|
||||
isHealthy,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function AppLayout({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
access={loaderData.access}
|
||||
configAvailable={loaderData.configAvailable}
|
||||
user={loaderData.user}
|
||||
/>
|
||||
<main className="container mt-4 mb-24 overscroll-contain">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface FooterProps {
|
||||
isDebug: boolean;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
export default function Footer({ isDebug, baseUrl }: FooterProps) {
|
||||
const [urlVisible, setUrlVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"fixed w-full bottom-0 left-0 z-50",
|
||||
"bg-mist-50 dark:bg-mist-950",
|
||||
"dark:border-t dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-2">
|
||||
<p className="text-xs">
|
||||
Headplane is free and open-source. Please consider{" "}
|
||||
<Link external styled to="https://tale.me/sponsor">
|
||||
sponsoring
|
||||
</Link>{" "}
|
||||
to support development.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{isDebug && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 font-medium",
|
||||
"bg-amber-100 text-amber-800",
|
||||
"dark:bg-amber-900/50 dark:text-amber-300",
|
||||
)}
|
||||
>
|
||||
Debug
|
||||
</span>
|
||||
)}
|
||||
<p className="text-mist-500 dark:text-mist-400">
|
||||
{__VERSION__} ·{" "}
|
||||
{urlVisible ? (
|
||||
<code>{baseUrl}</code>
|
||||
) : (
|
||||
<span aria-hidden="true">•••••</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={urlVisible ? "Hide server URL" : "Show server URL"}
|
||||
className={cn(
|
||||
"ml-1 inline-flex align-middle rounded-xs p-0.5",
|
||||
"text-mist-400 hover:text-mist-600",
|
||||
"dark:text-mist-500 dark:hover:text-mist-300",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
)}
|
||||
onClick={() => setUrlVisible((v) => !v)}
|
||||
>
|
||||
{urlVisible ? <EyeOff size={12} /> : <Eye size={12} />}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
|
||||
import { NavLink, useLocation, useSubmit } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
import logoBg from "~/logo/dark-bg.svg";
|
||||
import logoDark from "~/logo/dark.svg";
|
||||
import logoLight from "~/logo/light.svg";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface HeaderProps {
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
configAvailable: boolean;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
|
||||
{ to: "/users", icon: Users, label: "Users", key: "users" },
|
||||
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
|
||||
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
|
||||
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
|
||||
] as const;
|
||||
|
||||
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
const submit = useSubmit();
|
||||
const location = useLocation();
|
||||
const isOnboarding = location.pathname.startsWith("/onboarding");
|
||||
const showTabs = access.ui && !isOnboarding;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
|
||||
"dark:border-b dark:border-mist-800 shadow-inner",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-8">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<picture>
|
||||
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
|
||||
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
|
||||
<img src={logoBg} alt="Headplane logo" />
|
||||
</picture>
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
|
||||
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
isActive
|
||||
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
|
||||
: "text-mist-600 dark:text-mist-300",
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={tab.to}
|
||||
>
|
||||
<tab.icon className="w-4" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 rounded-full p-1">
|
||||
<CircleQuestionMark className="w-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem>
|
||||
<Link external to="https://headplane.net">
|
||||
Docs
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://headscale.net">
|
||||
Headscale
|
||||
</Link>
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
<Link external to="https://tailscale.com/download">
|
||||
Download
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 overflow-hidden rounded-full">
|
||||
{user.picture ? (
|
||||
<img alt={user.name} className="size-8" src={user.picture} />
|
||||
) : (
|
||||
<CircleUser className="size-8" />
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent align="end">
|
||||
<MenuItem disabled>
|
||||
<div className="text-mist-900 dark:text-mist-50">
|
||||
{user.subject === "api_key" ? (
|
||||
<>
|
||||
<p className="font-bold">API Key</p>
|
||||
<p>{user.name}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-bold">{user.name}</p>
|
||||
{user.email && <p>{user.email}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => submit({}, { action: "/logout", method: "POST" })}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user