feat: refactor several components and clean up ui

This commit is contained in:
Aarnav Tale
2026-03-09 22:09:58 -04:00
parent a53fd31d27
commit 6470f5a821
49 changed files with 1522 additions and 1355 deletions
+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 63 36" height="36" width="63" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M47.0239 10.6522L52.1669 1.64895C52.4571 1.14423 52.2816 0.503129 51.7889 0.209825C51.2962 -0.0834073 50.655 0.0871143 50.3716 0.59178L45.161 9.71096C41.1856 7.87622 36.7108 6.8531 31.8985 6.8531C27.0861 6.8531 22.6113 7.87622 18.6359 9.71096L13.4253 0.59178C13.1351 0.0870609 12.5007 -0.0834429 12.0012 0.209825C11.5018 0.50304 11.333 1.14423 11.6232 1.64895L16.7663 10.6522C7.89757 15.5085 1.89061 24.5799 0.898438 35.1997H62.8984C61.9063 24.5799 55.8993 15.5085 47.0238 10.6522H47.0239ZM17.664 26.4011C16.2264 26.4011 15.0655 25.2211 15.0655 23.7751C15.0655 22.3223 16.2331 21.1492 17.664 21.1492C19.1016 21.1492 20.2625 22.3292 20.2625 23.7751C20.2695 25.2211 19.1016 26.4011 17.664 26.4011ZM46.1262 26.4011C44.6886 26.4011 43.5277 25.2211 43.5277 23.7751C43.5277 22.3223 44.6953 21.1492 46.1262 21.1492C47.5638 21.1492 48.7247 22.3292 48.7247 23.7751C48.7318 25.2211 47.5638 26.4011 46.1262 26.4011Z" />
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 39 46" height="46" width="39" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M21.6443 4.03452C24.6748 0.0855621 28.8877 0.0663452 28.8877 0.0663452C28.8877 0.0663452 29.5143 3.779 26.5038 7.35551C23.2893 11.1745 19.6355 10.5496 19.6355 10.5496C19.6355 10.5496 18.9495 7.54621 21.6443 4.03452V4.03452ZM20.021 13.1503C21.58 13.1503 24.4733 11.0315 28.2395 11.0315C34.7224 11.0315 37.2728 15.5923 37.2728 15.5923C37.2728 15.5923 32.2847 18.1137 32.2847 24.2317C32.2847 31.1335 38.4984 33.5121 38.4984 33.5121C38.4984 33.5121 34.1548 45.5994 28.2878 45.5994C25.5931 45.5994 23.4982 43.8041 20.6589 43.8041C17.7656 43.8041 14.8944 45.6663 13.0241 45.6663C7.66679 45.6665 0.898438 34.2004 0.898438 24.9837C0.898438 15.9156 6.6274 11.1585 12.0009 11.1585C15.4941 11.1585 18.2049 13.1503 20.021 13.1503V13.1503Z" clip-rule="evenodd" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 896 B

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 55 40" height="40" width="55" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M0.613281 0.225403H54.3397V39.0278H0.613281V0.225403ZM8.36288 8.47665C8.75143 8.08811 9.38138 8.08811 9.76993 8.47665L14.7446 13.4513C15.1331 13.8399 15.1331 14.4698 14.7446 14.8584L9.76993 19.833C9.38138 20.2216 8.75143 20.2216 8.36288 19.833C7.97434 19.4445 7.97434 18.8145 8.36288 18.426L12.634 14.1548L8.36288 9.8837C7.97434 9.49515 7.97434 8.8652 8.36288 8.47665ZM16.534 22.1143C15.9845 22.1143 15.5391 22.5597 15.5391 23.1092C15.5391 23.6587 15.9845 24.1041 16.534 24.1041H25.4884C26.0379 24.1041 26.4833 23.6587 26.4833 23.1092C26.4833 22.5597 26.0379 22.1143 25.4884 22.1143H16.534Z" clip-rule="evenodd" fill-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 760 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="44" height="44" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.7588 27.4022C22.0336 22.6094 24.079 14.802 28.5236 6H6.00006C3.79092 6 2.00006 7.79086 2.00006 10V42C2.00006 44.2092 3.79092 46 6.00006 46H28.735C28.3672 43.6252 28.2408 41.2548 28.2502 39.0346C27.353 39.1516 26.4424 39.2148 25.5294 39.2148C22.3036 39.2148 19.106 38.427 16.4322 37.2544C13.7899 36.0954 11.5029 34.4902 10.2351 32.7296C9.76718 32.0798 9.91469 31.1736 10.5647 30.7056C11.2145 30.2378 12.1207 30.3852 12.5885 31.0352C13.4385 32.2156 15.2105 33.5518 17.5972 34.5986C19.9528 35.6318 22.7552 36.3148 25.5294 36.3148C26.4688 36.3148 27.4114 36.2364 28.339 36.0926C28.4724 33.65 28.7306 31.5148 28.9364 29.9336H24.1764C22.8458 29.9336 21.6766 28.8396 21.7588 27.4022ZM31.156 38.4622C32.3768 38.1404 33.5446 37.729 34.6266 37.2544C37.2692 36.0954 39.5562 34.4902 40.8238 32.7296C41.2918 32.0798 41.1442 31.1736 40.4944 30.7056C39.8444 30.2378 38.9382 30.3852 38.4704 31.0352C37.6204 32.2156 35.8486 33.5518 33.4618 34.5986C32.7714 34.9014 32.0426 35.174 31.2876 35.4082C31.4398 33.1888 31.688 31.2466 31.8794 29.7964C32.073 28.328 30.9296 27.0336 29.4554 27.0336H24.692C25.0766 22.4392 27.179 14.7405 31.7868 6H46C48.2092 6 50 7.79086 50 10V42C50 44.2092 48.2092 46 46 46H31.6716C31.2398 43.446 31.1176 40.8638 31.156 38.4622ZM15.0001 14.55C15.8009 14.55 16.4501 15.1992 16.4501 16V20.7059C16.4501 21.5067 15.8009 22.1558 15.0001 22.1558C14.1993 22.1558 13.5501 21.5067 13.5501 20.7059V16C13.5501 15.1992 14.1993 14.55 15.0001 14.55ZM38.45 16C38.45 15.1992 37.8008 14.55 37 14.55C36.1992 14.55 35.55 15.1992 35.55 16V20.7059C35.55 21.5067 36.1992 22.1558 37 22.1558C37.8008 22.1558 38.45 21.5067 38.45 20.7059V16Z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg viewBox="0 0 46 46" height="46" width="46" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M0.296875 6.56799L18.9443 4.0173V21.9762H0.296875V6.56799ZM21.1903 3.66333L45.8969 0.0663452V21.8252H21.1903V3.66333ZM0.296875 23.8762H18.9443V41.8872L0.296875 39.2844V23.8762ZM21.1903 24.1157H45.8969V45.6663L21.1903 42.1787" />
</svg>

After

Width:  |  Height:  |  Size: 354 B

+1 -1
View File
@@ -20,7 +20,7 @@ export default function Button({ variant = "light", ...props }: ButtonProps) {
ref={ref}
{...buttonProps}
className={cn(
"w-fit rounded-lg px-3.5 py-2 text-sm leading-tight",
"w-fit rounded-md px-3.5 py-2 text-sm leading-tight",
"transition-colors duration-100",
"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",
+30 -24
View File
@@ -15,7 +15,6 @@ import {
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import Card from "~/components/Card";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Text from "~/components/Text";
import Title from "~/components/Title";
@@ -101,7 +100,13 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn("outline-hidden rounded-lg w-full max-w-lg", "bg-white dark:bg-mist-900")}
className={cn(
"w-full max-w-lg rounded-xl p-4",
"outline-hidden",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
"shadow-overlay",
)}
method={method ?? "POST"}
onSubmit={(event) => {
if (onSubmit) {
@@ -112,25 +117,23 @@ function Panel(props: DialogPanelProps) {
}}
ref={ref}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === "unactionable" ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Card>
<div className="flex flex-col gap-4">{children}</div>
<div className="mt-5 flex justify-end gap-3">
{variant === "unactionable" ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === "destructive" ? "danger" : "heavy"}
>
Confirm
</Button>
</>
)}
</div>
</Form>
);
}
@@ -155,9 +158,9 @@ function DModal(props: DModalProps) {
{...underlayProps}
aria-hidden="true"
className={cn(
"fixed inset-0 h-screen w-screen z-20",
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center",
"bg-mist-900/15 dark:bg-mist-900/30",
"bg-mist-900/30 dark:bg-mist-950/60",
"entering:animate-in exiting:animate-out",
"entering:fade-in entering:duration-100 entering:ease-out",
"exiting:fade-out exiting:duration-50 exiting:ease-in",
@@ -165,7 +168,10 @@ function DModal(props: DModalProps) {
/>
<div
{...modalProps}
className={cn("fixed inset-0 h-screen w-screen z-20", "flex items-center justify-center")}
className={cn(
"fixed inset-0 z-20 h-screen w-screen",
"flex items-center justify-center p-4",
)}
>
{children}
</div>
-68
View File
@@ -1,68 +0,0 @@
import { CircleX } from "lucide-react";
import Link from "~/components/link";
import cn from "~/utils/cn";
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
}
export default function Footer({ url, debug, healthy }: FooterProps) {
return (
<footer
className={cn(
"fixed w-full bottom-0 left-0 z-40 h-12",
"flex items-center justify-center",
"bg-mist-50 dark:bg-mist-950",
"dark:border-t dark:border-mist-800",
)}
>
<div
className={cn(
"grid grid-rows-1 items-center container",
!healthy && "md:grid-cols-[1fr_auto] grid-cols-1",
)}
>
<div className={cn("text-xs leading-none", !healthy && "hidden md:block")}>
<p>
Headplane is free. Please consider{" "}
<Link isExternal to="https://github.com/sponsors/tale" name="Aarnav's GitHub Sponsors">
donating
</Link>{" "}
to support development.{" "}
</p>
<p className="opacity-75">
Version: {__VERSION__}
{" — "}
Connecting to{" "}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
"blur-sm hover:blur-none focus:blur-none transition",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 rounded-xs",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
>
{url}
</button>
{debug && " (Debug mode enabled)"}
</p>
</div>
{!healthy ? (
<div
className={cn(
"flex gap-1.5 items-center p-2 rounded-md text-sm",
"bg-red-500 text-white font-semibold",
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
</footer>
);
}
-165
View File
@@ -1,165 +0,0 @@
import { CircleUser, Globe2, Lock, Server, Settings, Users } from "lucide-react";
import type { ReactNode } from "react";
import { NavLink, useSubmit } from "react-router";
import Logo from "~/components/Logo";
import Menu from "~/components/Menu";
import { AuthSession } from "~/server/web/sessions";
import cn from "~/utils/cn";
interface Props {
configAvailable: boolean;
onboarding: boolean;
user?: AuthSession["user"];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
}
function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
className={({ isActive }) =>
cn(
"px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5",
"after:absolute after:bottom-0 after:left-3 after:right-3",
"after:h-0.5 after:bg-mist-900 dark:after:bg-mist-200",
"hover:bg-mist-200 dark:hover:bg-mist-900",
"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 ? "after:visible" : "after:invisible",
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
</div>
);
}
function Link({ href, text }: LinkProps) {
return (
<a
className={cn(
"hidden sm:block hover:underline text-sm",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 rounded-md",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
return (
<header
className={cn(
"bg-mist-100 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-2">
<Logo />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
<Link href="https://tailscale.com/download" text="Download" />
<Link href="https://github.com/tale/headplane" text="GitHub" />
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton className={cn(data.user.picture ? "p-0" : "")} label="User">
{data.user.picture ? (
<img
alt={data.user.name}
className="h-8 w-8 rounded-full"
src={data.user.picture}
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
disabledKeys={["profile"]}
onAction={(key) => {
if (key === "logout") {
submit(
{},
{
method: "POST",
action: "/logout",
},
);
}
}}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
<div className="text-black dark:text-mist-50">
<p className="font-bold">{data.user.name}</p>
<p>{data.user.email}</p>
</div>
</Menu.Item>
<Menu.Item key="logout" textValue="Logout">
<p className="text-red-500 dark:text-red-400">Logout</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
) : undefined}
</div>
</div>
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink icon={<Server className="w-5" />} name="Machines" to="/machines" />
) : undefined}
{data.access.users ? (
<TabLink icon={<Users className="w-5" />} name="Users" to="/users" />
) : undefined}
{data.access.policy ? (
<TabLink icon={<Lock className="w-5" />} name="Access Control" to="/acls" />
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink icon={<Globe2 className="w-5" />} name="DNS" to="/dns" />
) : undefined}
{data.access.settings ? (
<TabLink icon={<Settings className="w-5" />} name="Settings" to="/settings" />
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}
-10
View File
@@ -1,10 +0,0 @@
import cn from '~/utils/cn';
import LogoSvg from '../../public/logo-light.svg';
export interface LogoProps {
className?: string;
}
export default function Logo({ className }: LogoProps) {
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
}
+7 -7
View File
@@ -17,7 +17,6 @@ export function getErrorMessage(error: Error | unknown): {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: "Headscale API Error",
jsxMessage: (
<>
<Card.Text>
@@ -37,13 +36,13 @@ export function getErrorMessage(error: Error | unknown): {
)}
</>
),
title: "Headscale API Error",
};
}
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: "Invalid response from Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
@@ -84,13 +83,13 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Invalid response from Headscale API",
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: "Cannot connect to Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
@@ -112,11 +111,11 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Cannot connect to Headscale API",
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
@@ -126,18 +125,18 @@ export function getErrorMessage(error: Error | unknown): {
Status Text: <strong>{error.data}</strong>
</>
),
title: `Error ${error.status}`,
};
}
if (!(error instanceof Error)) {
return {
title: "Unexpected Error",
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please consider reporting
filing an issue on the{" "}
<Link isExternal name="Headplane GitHub" to="https://github.com/tale/headplane/issues">
<Link external styled to="https://github.com/tale/headplane/issues">
Headplane GitHub
</Link>{" "}
repository with the details below.
@@ -148,6 +147,7 @@ export function getErrorMessage(error: Error | unknown): {
</pre>
</>
),
title: "Unexpected Error",
};
}
@@ -166,11 +166,11 @@ export function getErrorMessage(error: Error | unknown): {
}
return {
jsxMessage: rootError.message,
title:
rootError.name.length > 0 && rootError.name !== "Error"
? `Error: ${rootError.name}`
: "Error",
jsxMessage: rootError.message,
};
}
+17 -20
View File
@@ -1,51 +1,48 @@
import { ExternalLink } from "lucide-react";
import type { JSX } from "react";
import type { JSX, ReactNode } from "react";
import { Link as RouterLink } from "react-router";
import cn from "~/utils/cn";
const linkStyles = cn(
"inline-flex items-center gap-x-0.5",
"text-blue-500 hover:text-blue-700",
"dark:text-blue-400 dark:hover:text-blue-300",
"focus:ring-offset-1 rounded-md",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
);
export type LinkProps =
| {
isExternal: true;
external: true;
to: string;
name: string;
children: string;
children: ReactNode;
className?: string;
styled?: boolean;
}
| {
isExternal?: false;
external?: false;
to: string;
children?: string;
children?: ReactNode;
className?: string;
};
export default function Link(props: LinkProps): JSX.Element {
if (props.isExternal) {
if (props.external) {
return (
<a
href={props.to}
aria-label={props.name}
target="_blank"
rel="noreferrer"
className={cn(linkStyles, props.className)}
className={cn(
props.styled && [
"inline-flex items-center gap-x-0.5",
"text-blue-500 hover:text-blue-700",
"dark:text-blue-400 dark:hover:text-blue-300",
],
props.className,
)}
>
{props.children}
<ExternalLink className="w-3.5" />
{props.styled && <ExternalLink className="w-3.5" />}
</a>
);
}
return (
<RouterLink to={props.to} className={cn(linkStyles, props.className)}>
<RouterLink to={props.to} className={props.className}>
{props.children}
</RouterLink>
);
+103
View File
@@ -0,0 +1,103 @@
// oxlint-disable react/no-multi-comp
import { Menu as BaseMenu } from "@base-ui/react/menu";
import type { ComponentProps, JSX, ReactNode } from "react";
import cn from "~/utils/cn";
const SIDE_OFFSET = 8;
export const Menu = ({
children,
disabled,
}: {
children: ReactNode;
disabled?: boolean;
}): JSX.Element => <BaseMenu.Root disabled={disabled}>{children}</BaseMenu.Root>;
export const MenuTrigger = ({
className,
children,
disabled,
onClick,
}: Pick<
ComponentProps<typeof BaseMenu.Trigger>,
"className" | "children" | "disabled" | "onClick"
>): JSX.Element => (
<BaseMenu.Trigger
className={cn(
"inline-flex items-center justify-center",
"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",
"disabled:opacity-50 disabled:cursor-not-allowed",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Trigger>
);
export const MenuContent = ({
children,
className,
side = "bottom",
sideOffset = SIDE_OFFSET,
align = "start",
}: {
children: ReactNode;
className?: string;
side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
sideOffset?: number;
align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
}): JSX.Element => (
<BaseMenu.Portal>
<BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align}>
<BaseMenu.Popup
className={cn(
"min-w-50 rounded-lg py-1",
"bg-white dark:bg-mist-900",
"shadow-lg dark:shadow-none",
"border border-mist-200 dark:border-mist-700",
"focus:outline-hidden",
className,
)}
>
{children}
</BaseMenu.Popup>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
export const MenuItem = ({
className,
variant,
children,
disabled,
onClick,
}: Pick<ComponentProps<typeof BaseMenu.Item>, "className" | "children" | "disabled" | "onClick"> & {
variant?: "danger";
}): JSX.Element => (
<BaseMenu.Item
className={cn(
"py-2 px-3 mx-1 rounded-md",
"select-none cursor-pointer",
"focus:outline-hidden",
"text-mist-700 dark:text-mist-300",
"data-highlighted:bg-mist-100/50 dark:data-highlighted:bg-mist-800",
"data-disabled:text-mist-400 dark:data-disabled:text-mist-600 data-disabled:cursor-default",
variant === "danger" && "text-red-500 dark:text-red-400",
className,
)}
disabled={disabled}
onClick={onClick}
>
{children}
</BaseMenu.Item>
);
export const MenuSeparator = ({ className }: { className?: string }): JSX.Element => (
<BaseMenu.Separator
className={cn("mx-2 my-1 border-t border-mist-200 dark:border-mist-800", className)}
/>
);
+106
View File
@@ -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>
);
}
+69
View File
@@ -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__} &middot;{" "}
{urlVisible ? (
<code>{baseUrl}</code>
) : (
<span aria-hidden="true">&bull;&bull;&bull;&bull;&bull;</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>
);
}
+152
View File
@@ -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>
);
}
-188
View File
@@ -1,188 +0,0 @@
import { Icon } from "@iconify/react";
import { Form } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/link";
import Options from "~/components/Options";
import toast from "~/utils/toast";
interface NoAccessProps {
linkedUserName?: string;
osValue?: string;
}
export default function NoAccess({ linkedUserName, osValue }: NoAccessProps) {
return (
<main className="container mt-6 mb-24 overscroll-contain">
<div className="mx-auto flex max-w-xl flex-col gap-4">
{linkedUserName ? (
<Card className="max-w-xl" variant="flat">
<p className="text-sm">
Your account is linked to Headscale user <strong>{linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-xl" variant="flat">
<Card.Title className="mb-3">Access your network via Tailscale</Card.Title>
<Card.Text>
You don't have dashboard access, but you can still connect to your Headscale network.
Install Tailscale on your device to get started.
</Card.Text>
<Options
className="my-4"
defaultSelectedKey={osValue ?? "linux"}
label="Download Selector"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast("Copied to clipboard");
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
Click this button to copy the command.{" "}
<Link
isExternal
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
isExternal
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{"."}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card className="max-w-xl" variant="flat">
<Card.Title className="mb-3">Need dashboard access?</Card.Title>
<Card.Text>
Your account is signed in but doesn't have permission to manage the dashboard. Contact
an administrator to request access.
</Card.Text>
<Form action="/logout" className="mt-4" method="POST">
<Button className="w-full" type="submit" variant="light">
Sign out
</Button>
</Form>
</Card>
</div>
</main>
);
}
+27 -22
View File
@@ -1,11 +1,11 @@
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import Header from "~/layout/header";
import { Capabilities } from "~/server/web/roles";
import { getUserDisplayName } from "~/utils/user";
import { Route } from "./+types/shell";
import type { Route } from "./+types/shell";
import NoAccess from "./no-access";
// This loads the bare minimum for the application to function
@@ -31,13 +31,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const user =
principal.kind === "oidc"
? {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { subject: "api_key", name: principal.displayName };
: { name: principal.displayName, subject: "api_key" };
let linkedUserName: string | undefined;
let osValue: string | undefined;
@@ -57,31 +57,31 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
switch (os?.[0]) {
case "Windows":
case "Windows": {
osValue = "windows";
break;
case "Mac OS X":
}
case "Mac OS X": {
osValue = "macos";
break;
}
case "iPhone":
case "iPad":
case "iPad": {
osValue = "ios";
break;
case "Android":
}
case "Android": {
osValue = "android";
break;
default:
}
default: {
osValue = "linux";
break;
}
}
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user,
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
@@ -90,11 +90,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
onboarding: request.url.endsWith("/onboarding"),
noAccess,
linkedUserName,
osValue,
config: context.hs.c,
configAvailable: context.hs.readable(),
debug: context.config.debug,
healthy: await api.isHealthy(),
linkedUserName,
noAccess,
onboarding: request.url.endsWith("/onboarding"),
osValue,
url: context.config.headscale.public_url ?? context.config.headscale.url,
user,
};
} catch {
return redirect("/login", {
@@ -109,7 +114,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
<Header {...loaderData} />
<Header user={loaderData.user} />
<NoAccess linkedUserName={loaderData.linkedUserName} osValue={loaderData.osValue} />
<Footer {...loaderData} />
</>
@@ -118,7 +123,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
<Header user={loaderData.user} />
<Outlet />
<Footer {...loaderData} />
</>

Before

Width:  |  Height:  |  Size: 901 B

After

Width:  |  Height:  |  Size: 901 B

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

Before

Width:  |  Height:  |  Size: 926 B

After

Width:  |  Height:  |  Size: 926 B

Before

Width:  |  Height:  |  Size: 742 B

After

Width:  |  Height:  |  Size: 742 B

+16 -18
View File
@@ -2,7 +2,6 @@ import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
@@ -15,27 +14,26 @@ export default [
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout("layouts/shell.tsx", [
// All the main logged-in routes
layout("layout/app.tsx", [
index("routes/home.tsx"),
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout("layouts/dashboard.tsx", [
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
];
+176 -207
View File
@@ -1,220 +1,189 @@
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
import Notice from "~/components/Notice";
import Tabs from "~/components/Tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import { Differ, Editor } from "./components/cm.client";
export const loader = aclLoader;
export const action = aclAction;
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
if (fetcher.data.success === true) {
toast("Updated policy");
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
onChange={setCodePolicy}
value={codePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control List policy. Please
contact your administrator to request access or to make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your Headscale
configuration. This means that the ACL file cannot be edited through the web interface. In
order to resolve this, you'll need to set <Code>policy.mode</Code> to{" "}
<Code>database</Code> in your Headscale configuration.
</Notice>
) : undefined}
<h1 className="mb-4 text-2xl font-medium">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network. You can find more
information about the ACL file in the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
and the{" "}
<Link external styled to="https://headscale.net/stable/ref/acls/">
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice title={fetcher.data.error.split(":")[0] ?? "Error"} variant="error">
{fetcher.data.error.split(":").slice(1).join(": ") ??
"An unknown error occurred while trying to update the ACL policy."}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="mt-4 w-1/2 text-center">
Previewing rules is not available yet. This feature is still in development and is
pretty complicated to implement. Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled || fetcher.state !== "idle" || codePolicy.length === 0 || codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append("policy", codePolicy);
fetcher.submit(formData, { method: "PATCH" });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={disabled || fetcher.state !== "idle" || codePolicy === policy}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes("reading policy from path") &&
error.data.rawData.includes("no such file or directory")
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does not exist on the
server. This usually indicates that Headscale is running in <Code>file</Code> mode for
ACLs, and the specified policy file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you can take:
</Card.Text>
<ul className="mt-2 ml-4 list-outside list-disc space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale configuration.
</li>
<li>
Alternatively, you can switch Headscale to use <Code>database</Code> mode for ACLs by
updating your Headscale configuration. This will allow Headplane to manage the ACL
policy directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
throw error;
}
+14 -12
View File
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
@@ -34,11 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
isExternal
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -54,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
case "INVALID_API_KEY": {
messages.push({
key: error,
node: (
@@ -65,8 +61,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT":
case "MISSING_AUTHORIZATION_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -77,8 +74,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT":
case "MISSING_TOKEN_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -89,8 +87,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT":
case "MISSING_USERINFO_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -101,8 +100,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_REQUIRED_CLAIMS":
case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
@@ -113,8 +113,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "UNKNOWN_ERROR":
case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
@@ -125,6 +126,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
}
}
+5 -5
View File
@@ -27,7 +27,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
// For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
@@ -63,14 +63,14 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
// Been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
// Which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
@@ -103,8 +103,8 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
isExternal
name="Headplane Common Issues"
external
styled
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
+116 -140
View File
@@ -1,153 +1,129 @@
import { Info } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
import { Info } from "lucide-react";
import { Form, useSubmit } from "react-router";
import Button from "~/components/Button";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
import AddNS from "../dialogs/add-ns";
interface Props {
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
}
export default function ManageNS({
nameservers,
isDisabled,
overrideLocalDns,
}: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
/>
))}
export default function ManageNS({ nameservers, isDisabled, overrideLocalDns }: Props) {
return (
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS queries.{" "}
<Link external styled to="https://tailscale.com/kb/1054/dns">
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
isDisabled={isDisabled}
isGlobal={key === "global"}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
overrideLocalDns,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
function NameserverList({ isGlobal, isDisabled, nameservers, overrideLocalDns, name }: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
{isGlobal ? (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: 'override_dns',
override_dns: v ? 'true' : 'false',
},
{
method: 'POST',
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input
name="split_name"
type="hidden"
value={isGlobal ? 'global' : name}
/>
<Button
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
return (
<div className="mb-8">
<div className="mb-2 flex items-center justify-between">
{isGlobal ? (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-md font-medium opacity-80">Global Nameservers</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve names outside the
tailnet. When disabled (default), devices will prefer their local DNS
configuration.
<Link external styled to="https://tailscale.com/kb/1054/dns#global-nameservers">
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: "override_dns",
override_dns: v ? "true" : "false",
},
{
method: "POST",
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input name="split_name" type="hidden" value={isGlobal ? "global" : name} />
<Button
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
+1 -5
View File
@@ -20,11 +20,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now, only <Code>A</Code>{" "}
and <Code>AAAA</Code> records are supported.{" "}
<Link
isExternal
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
>
<Link external styled to="https://headscale.net/stable/ref/dns">
Learn More
</Link>
</p>
+153
View File
@@ -0,0 +1,153 @@
import { Check, Copy } from "lucide-react";
import { Form, redirect } from "react-router";
import androidSvg from "~/assets/android.svg";
import iosSvg from "~/assets/ios.svg";
import linuxSvg from "~/assets/linux.svg";
import macosSvg from "~/assets/macos.svg";
import windowsSvg from "~/assets/windows.svg";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/link";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (context.auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const users = await api.getUsers();
const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
// API unavailable, skip linked user resolution
}
}
return { linkedUserName };
}
const downloads = [
{
href: "https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe",
icon: windowsSvg,
name: "Windows",
note: "Windows 10+",
},
{
href: "https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg",
icon: macosSvg,
name: "macOS",
note: "macOS Big Sur+",
},
{
href: "https://apps.apple.com/us/app/tailscale/id1470499037",
icon: iosSvg,
name: "iOS",
note: "iOS 15+",
},
{
href: "https://play.google.com/store/apps/details?id=com.tailscale.ipn",
icon: androidSvg,
name: "Android",
note: "Android 8+",
},
];
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<div className="mx-auto mt-6 mb-24 flex max-w-2xl flex-col gap-4">
{loaderData.linkedUserName && (
<Card variant="raised" className="flex max-w-2xl items-center gap-4">
<Check className="inline-flex size-4" />
<Card.Text className="text-sm">
Your account is linked to Headscale user <strong>{loaderData.linkedUserName}</strong>.
</Card.Text>
</Card>
)}
<Card variant="flat" className="max-w-2xl">
<Card.Title>Access your network via Tailscale</Card.Title>
<Card.Text className="mt-1">
You've successfully authenticated but don't have access to the dashboard. You can still
connect to your Headscale network by installing Tailscale.
</Card.Text>
<div className="mt-4 rounded-lg border border-mist-200 p-3 dark:border-mist-700">
<div className="flex items-center gap-2">
<img alt="Linux" className="w-4" src={linuxSvg} />
<span className="text-sm font-medium">Linux</span>
</div>
<div className="mt-2 flex items-center gap-2">
<code
className={cn(
"flex-1 rounded-md px-3 py-2 text-xs h-8",
"bg-mist-100 dark:bg-mist-800",
)}
>
curl -fsSL https://tailscale.com/install.sh | sh
</code>
<Button
className="h-8 p-1 px-2"
variant="ghost"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast("Copied to clipboard");
}}
>
<Copy className="size-4" />
</Button>
</div>
<p className="mt-1 text-xs text-mist-500 dark:text-mist-400">
<Link
external
styled
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
{downloads.map((dl) => (
<a
key={dl.name}
className={cn(
"flex flex-col items-center gap-2 rounded-lg p-3",
"border border-mist-200 dark:border-mist-700",
"hover:bg-mist-100 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",
"transition-colors",
)}
href={dl.href}
rel="noreferrer"
target="_blank"
>
<img alt={dl.name} className="h-6" src={dl.icon} />
<span className="text-sm font-medium">{dl.name}</span>
<span className="text-xs text-mist-500 dark:text-mist-400">{dl.note}</span>
</a>
))}
</div>
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
Need access to the dashboard? Contact your administrator to request access.
</Card.Text>
</Card>
</div>
);
}
+35 -30
View File
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import Chip from "~/components/Chip";
import Link from "~/components/link";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import StatusCircle from "~/components/StatusCircle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import { PopulatedNode } from "~/utils/node-info";
import type { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -76,29 +76,28 @@ export default function MachineRow({
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<Menu>
<MenuTrigger className="rounded-full bg-transparent p-1 hover:bg-mist-100 dark:hover:bg-mist-800">
<ChevronDown className="h-4 w-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast("Copied IP address to clipboard");
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</MenuTrigger>
<MenuContent align="end">
{ipOptions.map((ip) => (
<MenuItem
key={ip}
onClick={async () => {
await navigator.clipboard.writeText(ip);
toast("Copied IP address to clipboard");
}}
>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</MenuItem>
))}
</MenuContent>
</Menu>
</div>
</td>
@@ -190,25 +189,31 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case "exit-approved":
case "exit-waiting":
case "exit-waiting": {
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
}
case "subnet-approved":
case "subnet-waiting":
case "subnet-waiting": {
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
}
case "expired":
case "no-expiry":
case "no-expiry": {
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
}
case "tailscale-ssh":
case "tailscale-ssh": {
return <TailscaleSSHTag key={tag} />;
}
case "headplane-agent":
case "headplane-agent": {
return <HeadplaneAgentTag key={tag} />;
}
default:
default: {
return null;
}
}
});
}
+32 -33
View File
@@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
import { useState } from "react";
import Button from "~/components/Button";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { User } from "~/types";
import cn from "~/utils/cn";
import { PopulatedNode } from "~/utils/node-info";
@@ -140,39 +140,38 @@ export default function MachineMenu({
</Button>
)
) : undefined}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
className="w-10 bg-transparent py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800"
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
disabledKeys={node.expired ? ["expire"] : []}
onAction={(key) => setModal(key as Modal)}
<Menu disabled={isDisabled}>
<MenuTrigger
className={
isFullButton
? "gap-x-2 rounded-md border border-mist-200 bg-white px-3.5 py-2 text-sm font-medium hover:bg-mist-50 dark:border-mist-700 dark:bg-mist-800/50 dark:hover:bg-mist-700/50"
: "w-10 rounded-full bg-transparent p-1 hover:bg-mist-100 dark:hover:bg-mist-800"
}
>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
</Menu.Section>
<Menu.Section>
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
{isFullButton ? (
<>
<Cog className="h-5" />
<p>Machine Settings</p>
</>
) : (
<Ellipsis className="h-5" />
)}
</MenuTrigger>
<MenuContent>
<MenuItem onClick={() => setModal("rename")}>Edit machine name</MenuItem>
<MenuItem onClick={() => setModal("routes")}>Edit route settings</MenuItem>
<MenuItem onClick={() => setModal("tags")}>Edit ACL tags</MenuItem>
{supportsNodeOwnerChange && (
<MenuItem onClick={() => setModal("move")}>Change owner</MenuItem>
)}
<MenuSeparator />
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
Expire
</MenuItem>
<MenuItem variant="danger" onClick={() => setModal("remove")}>
Remove
</MenuItem>
</MenuContent>
</Menu>
</div>
);
+25 -30
View File
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import Select from "~/components/Select";
import type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
@@ -51,35 +51,30 @@ export default function NewMachine(data: NewMachineProps) {
</Select>
</Dialog.Panel>
</Dialog>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === "register") {
setPushDialog(true);
return;
}
if (key === "pre-auth") {
navigate("/settings/auth-keys");
}
}}
>
<Menu.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
<Menu disabled={data.isDisabled}>
<MenuTrigger className="rounded-md bg-indigo-500 px-3.5 py-2 text-sm font-semibold text-white hover:bg-indigo-500/90 dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80">
Add Device
</MenuTrigger>
<MenuContent>
<MenuItem
disabled={data.disabledKeys?.includes("register")}
onClick={() => setPushDialog(true)}
>
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</MenuItem>
<MenuItem
disabled={data.disabledKeys?.includes("pre-auth")}
onClick={() => navigate("/settings/auth-keys")}
>
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</MenuItem>
</MenuContent>
</Menu>
</>
);
+91 -103
View File
@@ -1,114 +1,102 @@
import { GlobeLock, RouteOff } from 'lucide-react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { PopulatedNode } from '~/utils/node-info';
import { GlobeLock, RouteOff } from "lucide-react";
import { useFetcher } from "react-router";
import Dialog from "~/components/Dialog";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
import { PopulatedNode } from "~/utils/node-info";
interface RoutesProps {
node: PopulatedNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
node: PopulatedNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
const fetcher = useFetcher();
const fetcher = useFetcher();
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">
No routes are advertised by this machine
</p>
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set('routes', [route].join(','));
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising IP ranges as subnet
routes.{" "}
<Link external styled to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">No routes are advertised by this machine</p>
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
form.set("routes", [route].join(","));
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</TableList.Item>
))}
</TableList>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
</TableList.Item>
) : (
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={node.customRouting.exitApproved}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set(
'routes',
node.customRouting.exitRoutes
.map((route) => route)
.join(','),
);
form.set("enabled", String(checked));
fetcher.submit(form, {
method: "POST",
});
}}
/>
</TableList.Item>
))}
</TableList>
<Dialog.Text className="mt-8 font-bold">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{" "}
<Link external styled to="https://tailscale.com/kb/1103/exit-nodes">
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
</TableList.Item>
) : (
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={node.customRouting.exitApproved}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
form.set("routes", node.customRouting.exitRoutes.map((route) => route).join(","));
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</Dialog>
);
form.set("enabled", String(checked));
fetcher.submit(form, {
method: "POST",
});
}}
/>
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</Dialog>
);
}
+15 -15
View File
@@ -22,13 +22,15 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}, [tag, tags]);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
const validNodeTags = useMemo(
() => existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [],
[tags],
);
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
@@ -53,7 +55,9 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
@@ -72,11 +76,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See the{" "}
<Link
isExternal
name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
>
<Link external styled to="https://tailscale.com/kb/1068/acl-tags">
Tailscale documentation
</Link>{" "}
for more information.
@@ -119,9 +119,9 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
{validNodeTags.map((nodeTag) => (
<Select.Item key={nodeTag}>{nodeTag}</Select.Item>
))}
</Select>
<Button
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
+7 -11
View File
@@ -45,18 +45,18 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort();
const tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
agent: context.agents?.agentID(),
existingTags: sortNodeTags(nodes),
magic,
node: enhancedNode,
stats: lookup?.[enhancedNode.nodeKey],
supportsNodeOwnerChange: supportsNodeOwnerChange,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
@@ -129,11 +129,7 @@ export default function Page({
<div className="mb-4 flex items-center justify-between">
<p>
Subnets let you expose physical network routes onto Tailscale.{" "}
<Link
isExternal
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
<Link external styled to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</p>
+23 -19
View File
@@ -42,17 +42,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
magic,
nodes,
populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url,
server: context.config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange,
users,
writable: writablePermission,
};
}
@@ -69,19 +69,26 @@ export default function Page({ loaderData }: Route.ComponentProps) {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
if (!query) {
return true;
}
if (node.givenName.toLowerCase().includes(query)) {
return true;
}
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) {
return true;
}
return false;
});
nodes = [...nodes].sort((a, b) => {
nodes = [...nodes].toSorted((a, b) => {
let comparison = 0;
switch (sortField) {
case "name":
case "name": {
comparison = a.givenName.localeCompare(b.givenName);
break;
}
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
@@ -119,13 +126,14 @@ export default function Page({ loaderData }: Route.ComponentProps) {
}
break;
}
case "lastSeen":
case "lastSeen": {
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
}
return sortDirection === "asc" ? comparison : -comparison;
@@ -150,11 +158,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link
isExternal
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
<Link external styled to="https://tailscale.com/kb/1372/manage-devices">
Learn more
</Link>
</p>
@@ -1,4 +1,5 @@
import { Key, useEffect, useRef, useState } from "react";
import type { Key } from "react";
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/Button";
@@ -21,9 +22,13 @@ interface AddAuthKeyProps {
}
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
if (!subject) return undefined;
if (!subject) {
return undefined;
}
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) return false;
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
});
}
@@ -76,7 +81,9 @@ export default function AddAuthKey({
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
@@ -203,11 +210,7 @@ export default function AddAuthKey({
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link
isExternal
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</Dialog.Text>
+11 -15
View File
@@ -32,7 +32,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// older versions don't support this endpoint
// Older versions don't support this endpoint
}
if (allKeys !== null) {
@@ -47,12 +47,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
keys = [];
const tagOnly = keysByUser.get(null);
if (tagOnly?.length) {
keys.push({ user: null, preAuthKeys: tagOnly });
keys.push({ preAuthKeys: tagOnly, user: null });
}
for (const user of users) {
const userKeys = keysByUser.get(user.id);
if (userKeys?.length) {
keys.push({ user, preAuthKeys: userKeys });
keys.push({ preAuthKeys: userKeys, user });
}
}
} else {
@@ -66,34 +66,34 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return { success: true as const, user, preAuthKeys };
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return { success: false as const, user, error, preAuthKeys: [] as const };
return { error, preAuthKeys: [] as const, success: false as const, user };
}
}),
);
keys = results
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
.map(({ user, preAuthKeys }) => ({ preAuthKeys, user }));
missing = results
.filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
.map(({ user, error }) => ({ error, user }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
return {
access: canGenerateAny || canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
keys,
missing,
users,
access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
url: context.config.headscale.public_url ?? context.config.headscale.url,
users,
};
}
@@ -192,11 +192,7 @@ export default function Page({
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
isExternal
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
<Link external styled to="https://tailscale.com/kb/1085/auth-keys/">
Tailscale documentation
</Link>
</p>
+2 -10
View File
@@ -28,11 +28,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
<p>
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
isExternal
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
<Link external styled to="https://tailscale.com/kb/1085/auth-keys/">
Tailscale documentation
</Link>
</p>
@@ -52,11 +48,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
domains, groups, or users to authenticate. This can be used to limit access to your
Tailnet to only certain users or groups and Headplane will also respect these settings
when authenticating.{" "}
<Link
isExternal
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
<Link external styled to="https://headscale.net/stable/ref/oidc/#basic-configuration">
Learn More
</Link>
</p>
@@ -28,12 +28,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
access: context.auth.can(principal, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
writable: context.hs.writable(),
};
}
@@ -69,11 +69,7 @@ export default function Page({ loaderData: { access, writable, settings } }: Rou
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
certain users or groups and Headplane will also respect these settings when
authenticating.{" "}
<Link
isExternal
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
<Link external styled to="https://headscale.net/stable/ref/oidc/#basic-configuration">
Learn More
</Link>
</p>
+3 -13
View File
@@ -21,28 +21,18 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{" "}
<Link isExternal name="OIDC Provider" to={oidc.issuer}>
<Link external styled to={oidc.issuer}>
OpenID Connect provider
</Link>
{". "}
Groups and user information do not automatically sync.{" "}
<Link
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
) : (
<>
Users are not managed externally. Using OpenID Connect can create a better
experience when using Headscale.{" "}
<Link
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
)}
</p>
+22 -17
View File
@@ -1,7 +1,7 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { Machine, User } from "~/types";
import Delete from "../dialogs/delete-user";
@@ -74,23 +74,28 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp
/>
)}
<Menu disabledKeys={disabledKeys}>
<Menu.IconButton
className="w-10 bg-transparent py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800"
label="User Options"
>
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="link">Link Headscale user</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</MenuTrigger>
<MenuContent>
<MenuItem disabled={disabledKeys.includes("rename")} onClick={() => setModal("rename")}>
Rename user
</MenuItem>
<MenuItem
disabled={disabledKeys.includes("reassign")}
onClick={() => setModal("reassign")}
>
Change role
</MenuItem>
<MenuItem disabled={disabledKeys.includes("link")} onClick={() => setModal("link")}>
Link Headscale user
</MenuItem>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
+86 -97
View File
@@ -1,105 +1,94 @@
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import RadioGroup from '~/components/RadioGroup';
import { Roles } from '~/server/web/roles';
import { User } from '~/types';
import Dialog from "~/components/Dialog";
import Link from "~/components/link";
import Notice from "~/components/Notice";
import RadioGroup from "~/components/RadioGroup";
import { Roles } from "~/server/web/roles";
import { User } from "~/types";
interface ReassignProps {
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({
user,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
>
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === 'owner' ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.headplaneRole === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind that I have not
fully implemented permissions yet and some things may be accessible to everyone. The only
fully completed role is Member.{" "}
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === "owner" ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== "owner")
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'admin':
return {
name: 'Admin',
desc: 'Can view the admin console, manage network, machine, and user settings.',
};
case 'network_admin':
return {
name: 'Network Admin',
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
};
case 'it_admin':
return {
name: 'IT Admin',
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
};
case 'auditor':
return {
name: 'Auditor',
desc: 'Can view the admin console.',
};
case 'member':
return {
name: 'Member',
desc: 'Cannot view the admin console.',
};
default:
return {
name: 'Unknown',
desc: 'Unknown',
};
}
switch (role) {
case "admin":
return {
name: "Admin",
desc: "Can view the admin console, manage network, machine, and user settings.",
};
case "network_admin":
return {
name: "Network Admin",
desc: "Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.",
};
case "it_admin":
return {
name: "IT Admin",
desc: "Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.",
};
case "auditor":
return {
name: "Auditor",
desc: "Can view the admin console.",
};
case "member":
return {
name: "Member",
desc: "Cannot view the admin console.",
};
default:
return {
name: "Unknown",
desc: "Unknown",
};
}
}
+22 -21
View File
@@ -9,7 +9,7 @@ import Link from "~/components/link";
import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Machine } from "~/types";
import type { Machine } from "~/types";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
@@ -28,25 +28,30 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = "linux";
switch (os?.[0]) {
case "Windows":
case "Windows": {
osValue = "windows";
break;
case "Mac OS X":
}
case "Mac OS X": {
osValue = "macos";
break;
}
case "iPhone":
case "iPad":
case "iPad": {
osValue = "ios";
break;
}
case "Android":
case "Android": {
osValue = "android";
break;
}
default:
default: {
osValue = "linux";
break;
}
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
@@ -87,11 +92,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}));
}
}
} catch (e) {
log.debug("api", "Failed to lookup nodes %o", e);
} catch (error) {
log.debug("api", "Failed to lookup nodes %o", error);
}
return {
firstMachine,
headscaleUsers,
linkedUserName,
needsUserLink,
osValue,
user: {
subject: principal.user.subject,
name: principal.profile.name,
@@ -99,11 +109,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
username: principal.profile.username,
picture: principal.profile.picture,
},
osValue,
firstMachine,
needsUserLink,
linkedUserName,
headscaleUsers,
};
}
@@ -216,8 +221,8 @@ export default function Page({
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
Click this button to copy the command.{" "}
<Link
isExternal
name="Linux installation script"
external
styled
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
@@ -270,14 +275,10 @@ export default function Page({
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
isExternal
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
<Link external styled to="https://apps.apple.com/ca/app/tailscale/id1475387142">
macOS App Store
</Link>
{"."}
.
</p>
</Options.Item>
<Options.Item
+1 -1
View File
@@ -2,7 +2,7 @@ import { eq, isNotNull } from "drizzle-orm";
import log from "~/utils/log";
import type { Route } from "../../layouts/+types/dashboard";
import type { Route } from "../../layout/+types/app";
import { ephemeralNodes } from "./schema";
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
+1
View File
@@ -234,6 +234,7 @@ export class AuthService {
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
* TODO: Get rid of this AI garbage
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
if (principal.kind === "api_key") {
+2 -1
View File
@@ -20,6 +20,7 @@
"format": "oxfmt"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@libsql/client": "0.17.0"
},
"devDependencies": {
@@ -65,7 +66,7 @@
"openid-client": "6.8.2",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.15.0",
"oxlint-tsgolint": "^0.16.0",
"postcss": "^8.5.6",
"react": "19.2.4",
"react-aria": "3.46.0",
+122 -32
View File
@@ -13,6 +13,9 @@ importers:
.:
dependencies:
'@base-ui/react':
specifier: ^1.2.0
version: 1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@libsql/client':
specifier: 0.17.0
version: 0.17.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
@@ -142,10 +145,10 @@ importers:
version: 0.35.0
oxlint:
specifier: ^1.50.0
version: 1.50.0(oxlint-tsgolint@0.15.0)
version: 1.50.0(oxlint-tsgolint@0.16.0)
oxlint-tsgolint:
specifier: ^0.15.0
version: 0.15.0
specifier: ^0.16.0
version: 0.16.0
postcss:
specifier: ^8.5.6
version: 8.5.6
@@ -392,6 +395,27 @@ packages:
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@base-ui/react@1.2.0':
resolution: {integrity: sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw==}
engines: {node: '>=14.0.0'}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@base-ui/utils@0.2.5':
resolution: {integrity: sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw==}
peerDependencies:
'@types/react': ^17 || ^18 || ^19
react: ^17 || ^18 || ^19
react-dom: ^17 || ^18 || ^19
peerDependenciesMeta:
'@types/react':
optional: true
'@codemirror/autocomplete@6.18.2':
resolution: {integrity: sha512-wJGylKtMFR/Ds6Gh01+OovXE/pncPiKZNNBKuC39pKnH+XK5d9+WsNqcrdxPjFPFTigRBqse0rfxw9UxrfyhPg==}
peerDependencies:
@@ -1241,6 +1265,21 @@ packages:
resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource-variable/inter@5.2.8':
resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==}
@@ -1590,33 +1629,33 @@ packages:
cpu: [x64]
os: [win32]
'@oxlint-tsgolint/darwin-arm64@0.15.0':
resolution: {integrity: sha512-d7Ch+A6hic+RYrm32+Gh1o4lOrQqnFsHi721ORdHUDBiQPea+dssKUEMwIbA6MKmCy6TVJ02sQyi24OEfCiGzw==}
'@oxlint-tsgolint/darwin-arm64@0.16.0':
resolution: {integrity: sha512-WQt5lGwRPJBw7q2KNR0mSPDAaMmZmVvDlEEti96xLO7ONhyomQc6fBZxxwZ4qTFedjJnrHX94sFelZ4OKzS7UQ==}
cpu: [arm64]
os: [darwin]
'@oxlint-tsgolint/darwin-x64@0.15.0':
resolution: {integrity: sha512-Aoai2wAkaUJqp/uEs1gml6TbaPW4YmyO5Ai/vOSkiizgHqVctjhjKqmRiWTX2xuPY94VkwOLqp+Qr3y/0qSpWQ==}
'@oxlint-tsgolint/darwin-x64@0.16.0':
resolution: {integrity: sha512-VJo29XOzdkalvCTiE2v6FU3qZlgHaM8x8hUEVJGPU2i5W+FlocPpmn00+Ld2n7Q0pqIjyD5EyvZ5UmoIEJMfqg==}
cpu: [x64]
os: [darwin]
'@oxlint-tsgolint/linux-arm64@0.15.0':
resolution: {integrity: sha512-4og13a7ec4Vku5t2Y7s3zx6YJP6IKadb1uA9fOoRH6lm/wHWoCnxjcfJmKHXRZJII81WmbdJMSPxaBfwN/S68Q==}
'@oxlint-tsgolint/linux-arm64@0.16.0':
resolution: {integrity: sha512-MPfqRt1+XRHv9oHomcBMQ3KpTE+CSkZz14wUxDQoqTNdUlV0HWdzwIE9q65I3D9YyxEnqpM7j4qtDQ3apqVvbQ==}
cpu: [arm64]
os: [linux]
'@oxlint-tsgolint/linux-x64@0.15.0':
resolution: {integrity: sha512-9b9xzh/1Harn3a+XiKTK/8LrWw3VcqLfYp/vhV5/zAVR2Mt0d63WSp4FL+wG7DKnI2T/CbMFUFHwc7kCQjDMzQ==}
'@oxlint-tsgolint/linux-x64@0.16.0':
resolution: {integrity: sha512-XQSwVUsnwLokMhe1TD6IjgvW5WMTPzOGGkdFDtXWQmlN2YeTw94s/NN0KgDrn2agM1WIgAenEkvnm0u7NgwEyw==}
cpu: [x64]
os: [linux]
'@oxlint-tsgolint/win32-arm64@0.15.0':
resolution: {integrity: sha512-nNac5hewHdkk5mowOwTqB1ZD76zB/FsUiyUvdCyupq5cG54XyKqSLEp9QGbx7wFJkWCkeWmuwRed4sfpAlKaeA==}
'@oxlint-tsgolint/win32-arm64@0.16.0':
resolution: {integrity: sha512-EWdlspQiiFGsP2AiCYdhg5dTYyAlj6y1nRyNI2dQWq4Q/LITFHiSRVPe+7m7K7lcsZCEz2icN/bCeSkZaORqIg==}
cpu: [arm64]
os: [win32]
'@oxlint-tsgolint/win32-x64@0.15.0':
resolution: {integrity: sha512-ioAY2XLpy83E2EqOLH9p1cEgj0G2qB1lmAn0a3yFV1jHQB29LIPIKGNsu/tYCClpwmHN79pT5KZAHZOgWxxqNg==}
'@oxlint-tsgolint/win32-x64@0.16.0':
resolution: {integrity: sha512-1ufk8cgktXJuJZHKF63zCHAkaLMwZrEXnZ89H2y6NO85PtOXqu4zbdNl0VBpPP3fCUuUBu9RvNqMFiv0VsbXWA==}
cpu: [x64]
os: [win32]
@@ -4204,8 +4243,8 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
oxlint-tsgolint@0.15.0:
resolution: {integrity: sha512-iwvFmhKQVZzVTFygUVI4t2S/VKEm+Mqkw3jQRJwfDuTcUYI5LCIYzdO5Dbuv4mFOkXZCcXaRRh0m+uydB5xdqw==}
oxlint-tsgolint@0.16.0:
resolution: {integrity: sha512-4RuJK2jP08XwqtUu+5yhCbxEauCm6tv2MFHKEMsjbosK2+vy5us82oI3VLuHwbNyZG7ekZA26U2LLHnGR4frIA==}
hasBin: true
oxlint@1.50.0:
@@ -4448,6 +4487,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
@@ -4619,6 +4661,9 @@ packages:
tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@@ -5294,6 +5339,30 @@ snapshots:
'@balena/dockerignore@1.0.2': {}
'@base-ui/react@1.2.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@base-ui/utils': 0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.11
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tabbable: 6.4.0
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@base-ui/utils@0.2.5(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@floating-ui/utils': 0.2.11
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
reselect: 5.1.1
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
'@types/react': 19.2.14
'@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.2)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)(@lezer/common@1.5.1)':
dependencies:
'@codemirror/language': 6.12.2
@@ -5801,6 +5870,23 @@ snapshots:
'@faker-js/faker@10.3.0': {}
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@floating-ui/utils@0.2.11': {}
'@fontsource-variable/inter@5.2.8': {}
'@formatjs/ecma402-abstract@2.3.6':
@@ -6147,22 +6233,22 @@ snapshots:
'@oxfmt/binding-win32-x64-msvc@0.35.0':
optional: true
'@oxlint-tsgolint/darwin-arm64@0.15.0':
'@oxlint-tsgolint/darwin-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/darwin-x64@0.15.0':
'@oxlint-tsgolint/darwin-x64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-arm64@0.15.0':
'@oxlint-tsgolint/linux-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/linux-x64@0.15.0':
'@oxlint-tsgolint/linux-x64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-arm64@0.15.0':
'@oxlint-tsgolint/win32-arm64@0.16.0':
optional: true
'@oxlint-tsgolint/win32-x64@0.15.0':
'@oxlint-tsgolint/win32-x64@0.16.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.50.0':
@@ -9007,16 +9093,16 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.35.0
'@oxfmt/binding-win32-x64-msvc': 0.35.0
oxlint-tsgolint@0.15.0:
oxlint-tsgolint@0.16.0:
optionalDependencies:
'@oxlint-tsgolint/darwin-arm64': 0.15.0
'@oxlint-tsgolint/darwin-x64': 0.15.0
'@oxlint-tsgolint/linux-arm64': 0.15.0
'@oxlint-tsgolint/linux-x64': 0.15.0
'@oxlint-tsgolint/win32-arm64': 0.15.0
'@oxlint-tsgolint/win32-x64': 0.15.0
'@oxlint-tsgolint/darwin-arm64': 0.16.0
'@oxlint-tsgolint/darwin-x64': 0.16.0
'@oxlint-tsgolint/linux-arm64': 0.16.0
'@oxlint-tsgolint/linux-x64': 0.16.0
'@oxlint-tsgolint/win32-arm64': 0.16.0
'@oxlint-tsgolint/win32-x64': 0.16.0
oxlint@1.50.0(oxlint-tsgolint@0.15.0):
oxlint@1.50.0(oxlint-tsgolint@0.16.0):
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.50.0
'@oxlint/binding-android-arm64': 1.50.0
@@ -9037,7 +9123,7 @@ snapshots:
'@oxlint/binding-win32-arm64-msvc': 1.50.0
'@oxlint/binding-win32-ia32-msvc': 1.50.0
'@oxlint/binding-win32-x64-msvc': 1.50.0
oxlint-tsgolint: 0.15.0
oxlint-tsgolint: 0.16.0
p-map@7.0.4: {}
@@ -9329,6 +9415,8 @@ snapshots:
require-from-string@2.0.2: {}
reselect@5.1.1: {}
resolve-pkg-maps@1.0.0: {}
retry@0.12.0: {}
@@ -9562,6 +9650,8 @@ snapshots:
tabbable@6.2.0: {}
tabbable@6.4.0: {}
tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.2.1):