feat: switch to mist color from tailwind

This commit is contained in:
Aarnav Tale
2026-03-08 23:48:03 -04:00
parent df5e04f239
commit 0ce411e92b
49 changed files with 2738 additions and 2991 deletions
+76 -79
View File
@@ -1,86 +1,83 @@
import { Check, Copy, Info } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import Tooltip from './Tooltip';
import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import Tooltip from "./Tooltip";
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
}
export default function Attribute({
name,
value,
tooltip,
isCopyable,
}: AttributeProps) {
return (
<dl className="flex gap-1 items-center text-sm">
<dt
className={cn(
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
'text-headplane-500 dark:text-headplane-400',
tooltip ? 'flex items-center gap-1' : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable
? [
'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700',
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="flex items-center gap-1.5 relative min-w-0 w-full"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
return (
<dl className="flex items-center gap-1 text-sm">
<dt
className={cn(
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
"text-mist-500 dark:text-mist-400",
tooltip ? "flex items-center gap-1" : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
"min-w-0 px-1.5 py-1 rounded-lg border border-transparent",
...(isCopyable
? [
"cursor-pointer hover:shadow-xs",
"hover:bg-mist-50 dark:hover:bg-mist-800",
"hover:border-mist-100 dark:hover:border-mist-700",
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="relative flex w-full min-w-0 items-center gap-1.5"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute("data-copied", false);
}
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="hidden size-4 data-copied:block" />
<Copy className="block size-4 data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
);
}
+40 -38
View File
@@ -1,43 +1,45 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaButtonOptions, useButton } from "react-aria";
export interface ButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light' | 'danger';
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
import cn from "~/utils/cn";
export interface ButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light" | "danger";
className?: string;
children?: React.ReactNode;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function Button({ variant = 'light', ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function Button({ variant = "light", ...props }: ButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: variant === 'danger'
? ['bg-red-500 text-white font-semibold', 'hover:bg-red-500/90']
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
return (
<button
ref={ref}
{...buttonProps}
className={cn(
"w-fit text-sm rounded-md px-3 py-2",
"focus:outline-hidden focus:ring-3",
props.isDisabled && "opacity-60 cursor-not-allowed",
...(variant === "heavy"
? [
"bg-mist-900 dark:bg-mist-50 font-semibold",
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
"text-mist-200 dark:text-mist-800",
]
: variant === "danger"
? ["bg-red-500 text-white font-semibold", "hover:bg-red-500/90"]
: [
"bg-mist-100 dark:bg-mist-800/50 font-medium",
"border border-mist-200 dark:border-mist-700",
"hover:bg-mist-200/90 dark:hover:bg-mist-700/50",
]),
props.className,
)}
>
{props.children}
</button>
);
}
+22 -21
View File
@@ -1,28 +1,29 @@
import React from 'react';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import React from "react";
import Text from "~/components/Text";
import Title from "~/components/Title";
import cn from "~/utils/cn";
interface Props extends React.HTMLProps<HTMLDivElement> {
variant?: 'raised' | 'flat';
variant?: "raised" | "flat";
}
function Card({ variant = 'raised', ...props }: Props) {
return (
<div
{...props}
className={cn(
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
function Card({ variant = "raised", ...props }: Props) {
return (
<div
{...props}
className={cn(
"w-full max-w-md rounded-lg p-5",
variant === "flat"
? "bg-transparent shadow-none"
: "bg-mist-50/50 dark:bg-mist-950/50 shadow-surface",
"border border-mist-100 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(Card, { Title, Text });
+23 -27
View File
@@ -1,32 +1,28 @@
import React from 'react';
import cn from '~/utils/cn';
import React from "react";
import cn from "~/utils/cn";
export interface ChipProps {
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
text: string;
className?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export default function Chip({
text,
className,
leftIcon,
rightIcon,
}: ChipProps) {
return (
<span
className={cn(
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
'inline-flex items-center gap-x-1',
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
export default function Chip({ text, className, leftIcon, rightIcon }: ChipProps) {
return (
<span
className={cn(
"h-5 text-xs py-0.5 px-1 rounded-md text-nowrap",
"text-mist-700 dark:text-mist-100",
"bg-mist-100 dark:bg-mist-700",
"inline-flex items-center gap-x-1",
className,
)}
>
{leftIcon}
{text}
{rightIcon}
</span>
);
}
+42 -41
View File
@@ -1,50 +1,51 @@
import { Check, Copy } from 'lucide-react';
import { HTMLProps } from 'react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import { Check, Copy } from "lucide-react";
import { HTMLProps } from "react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[] | number;
isCopyable?: boolean;
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
return (
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
>
{children}
{isCopyable && (
<button
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
return (
<code
className={cn(
"bg-mist-100 dark:bg-mist-800 px-1 py-0.5 font-mono",
"rounded-sm focus-within:outline-hidden focus-within:ring-2",
isCopyable && "relative pr-7",
className,
)}
>
{children}
{isCopyable && (
<button
className="absolute right-0 bottom-0"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join("") : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
svg.toggleAttribute('data-copied', true);
}
const svgs = event.currentTarget.querySelectorAll("svg");
for (const svg of svgs) {
svg.toggleAttribute("data-copied", true);
}
await navigator.clipboard.writeText(text);
toast('Copied to clipboard');
await navigator.clipboard.writeText(text);
toast("Copied to clipboard");
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute('data-copied', false);
}
}, 1000);
}}
type="button"
>
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
</button>
)}
</code>
);
setTimeout(() => {
for (const svg of svgs) {
svg.toggleAttribute("data-copied", false);
}
}, 1000);
}}
type="button"
>
<Check className="hidden h-4.5 w-4.5 p-1 data-copied:block" />
<Copy className="block h-4.5 w-4.5 p-1 data-copied:hidden" />
</button>
)}
</code>
);
}
+151 -163
View File
@@ -1,194 +1,182 @@
import React, { cloneElement, useEffect, useRef } from 'react';
import React, { cloneElement, useEffect, useRef } from "react";
import {
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from 'react-aria';
import { Form, type HTMLFormMethod } from 'react-router';
type AriaDialogProps,
type AriaModalOverlayProps,
Overlay,
useDialog,
useModalOverlay,
useOverlayTrigger,
} from "react-aria";
import { Form, type HTMLFormMethod } from "react-router";
import {
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} 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';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
type OverlayTriggerProps,
type OverlayTriggerState,
useOverlayTriggerState,
} 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";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: 'dialog',
},
state,
);
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
type: "dialog",
},
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
children: React.ReactNode;
variant?: "normal" | "destructive" | "unactionable";
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: 'alertdialog',
},
ref,
);
const { children, onSubmit, isDisabled, close, variant, method = "POST" } = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
...props,
role: "alertdialog",
},
ref,
);
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
return (
<Form
{...dialogProps}
className={cn("outline-hidden rounded-lg w-full max-w-lg", "bg-white dark:bg-mist-900")}
method={method ?? "POST"}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
}
close?.();
}}
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>
</Form>
);
close?.();
}}
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>
</Form>
);
}
interface DModalProps extends AriaModalOverlayProps {
children: React.ReactNode;
state: OverlayTriggerState;
children: React.ReactNode;
state: OverlayTriggerState;
}
function DModal(props: DModalProps) {
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
const { children, state } = props;
const ref = useRef<HTMLDivElement>(null);
const { modalProps, underlayProps } = useModalOverlay(props, state, ref);
if (!state.isOpen) {
return null;
}
if (!state.isOpen) {
return null;
}
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
'bg-headplane-900/15 dark:bg-headplane-900/30',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-100 entering:ease-out',
'exiting:fade-out exiting:duration-50 exiting:ease-in',
)}
/>
<div
{...modalProps}
className={cn(
'fixed inset-0 h-screen w-screen z-20',
'flex items-center justify-center',
)}
>
{children}
</div>
</Overlay>
);
return (
<Overlay>
<div
{...underlayProps}
aria-hidden="true"
className={cn(
"fixed inset-0 h-screen w-screen z-20",
"flex items-center justify-center",
"bg-mist-900/15 dark:bg-mist-900/30",
"entering:animate-in exiting:animate-out",
"entering:fade-in entering:duration-100 entering:ease-out",
"exiting:fade-out exiting:duration-50 exiting:ease-in",
)}
/>
<div
{...modalProps}
className={cn("fixed inset-0 h-screen w-screen z-20", "flex items-center justify-center")}
>
{children}
</div>
</Overlay>
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
Button,
IconButton,
Panel,
Title,
Text,
});
+61 -65
View File
@@ -1,71 +1,67 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link';
import cn from '~/utils/cn';
import { CircleX } from "lucide-react";
import Link from "~/components/Link";
import cn from "~/utils/cn";
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
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-headplane-50 dark:bg-headplane-950',
'dark:border-t dark:border-headplane-800',
)}
>
<div
className={cn(
'grid grid-rows-1 items-center container mx-auto',
!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
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 rounded-xs',
)}
>
{url}
</button>
{debug && ' (Debug mode enabled)'}
</p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl 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>
);
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 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 rounded-xs",
)}
>
{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>
);
}
+144 -173
View File
@@ -1,192 +1,163 @@
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';
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;
};
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;
href: string;
text: string;
}
interface TabLinkProps {
name: string;
to: string;
icon: ReactNode;
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-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-hidden focus:ring-3',
isActive ? 'after:visible' : 'after:invisible',
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
</div>
);
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-3",
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-3 rounded-md',
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
return (
<a
className={cn(
"hidden sm:block hover:underline text-sm",
"focus:outline-hidden focus:ring-3 rounded-md",
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
);
}
export default function Header(data: Props) {
const submit = useSubmit();
const submit = useSubmit();
return (
<header
className={cn(
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-b dark:border-headplane-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="w-8 h-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-headplane-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>
);
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>
);
}
+39 -41
View File
@@ -1,46 +1,44 @@
import React, { useRef } from 'react';
import { type AriaButtonOptions, useButton } from 'react-aria';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaButtonOptions, useButton } from "react-aria";
export interface IconButtonProps extends AriaButtonOptions<'button'> {
variant?: 'heavy' | 'light';
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
import cn from "~/utils/cn";
export interface IconButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light";
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({
variant = 'light',
...props
}: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
export default function IconButton({ variant = "light", ...props }: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
'bg-headplane-900 dark:bg-headplane-50 font-semibold',
'hover:bg-headplane-900/90 dark:hover:bg-headplane-50/90',
'text-headplane-200 dark:text-headplane-800',
]
: [
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
]),
props.className,
)}
>
{props.children}
</button>
);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
"rounded-full flex items-center justify-center p-1",
"focus:outline-hidden focus:ring-3",
props.isDisabled && "opacity-60 cursor-not-allowed",
...(variant === "heavy"
? [
"bg-mist-900 dark:bg-mist-50 font-semibold",
"hover:bg-mist-900/90 dark:hover:bg-mist-50/90",
"text-mist-200 dark:text-mist-800",
]
: [
"bg-mist-100 dark:bg-mist-700/30 font-medium",
"hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
]),
props.className,
)}
>
{props.children}
</button>
);
}
+74 -84
View File
@@ -1,93 +1,83 @@
import { Asterisk } from 'lucide-react';
import { useRef } from 'react';
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
import cn from '~/utils/cn';
import { Asterisk } from "lucide-react";
import { useRef } from "react";
import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
import cn from "~/utils/cn";
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
label: string;
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
'aria-label': label,
},
ref,
);
const {
labelProps,
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
...props,
label,
"aria-label": label,
},
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full">
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
)}
</label>
<input
{...inputProps}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid ? (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{errorMessage ?? validationErrors.join(' ')}
</div>
) : null}
</div>
);
return (
<div className="flex w-full flex-col">
<label
{...labelProps}
className={cn(
"text-xs font-medium px-3 mb-0.5",
"text-mist-700 dark:text-mist-100",
labelHidden && "sr-only",
)}
htmlFor={id}
>
{label}
{props.isRequired && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
</label>
<input
{...inputProps}
className={cn(
"rounded-md px-3 py-2",
"focus:outline-hidden focus:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid ? (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{errorMessage ?? validationErrors.join(" ")}
</div>
) : null}
</div>
);
}
+128 -134
View File
@@ -1,170 +1,164 @@
import React, { useRef, cloneElement } from 'react';
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from 'react-aria';
import { useMenu, useMenuItem, useMenuSection, useSeparator } from 'react-aria';
import { Item, Section } from 'react-stately';
import React, { useRef, cloneElement } from "react";
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from "react-aria";
import { useMenu, useMenuItem, useMenuSection, useSeparator } from "react-aria";
import { Item, Section } from "react-stately";
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from 'react-stately';
import Button, { ButtonProps } from '~/components/Button';
import IconButton, { IconButtonProps } from '~/components/IconButton';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
{},
state,
ref,
);
const { placement = "bottom", isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>({}, state, ref);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="min-w-[200px] rounded-md pt-1 pb-1 shadow-2xs focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
});
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
"aria-label": section["aria-label"],
});
const { separatorProps } = useSeparator({
elementType: 'li',
});
const { separatorProps } = useSeparator({
elementType: "li",
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn(
'mx-2 mt-1 mb-1 border-t',
'border-headplane-200 dark:border-headplane-800',
)}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn("mx-2 mt-1 mb-1 border-t", "border-mist-200 dark:border-mist-800")}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-hidden select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800 cursor-pointer',
)}
>
{item.rendered}
</li>
);
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
"py-2 px-3 mx-1 rounded-lg",
"focus:outline-hidden select-none",
isFocused && "bg-mist-100/50 dark:bg-mist-800",
isDisabled
? "text-mist-400 dark:text-mist-600"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800 cursor-pointer",
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
Button,
IconButton,
Panel,
Section,
Item,
});
+76 -94
View File
@@ -1,102 +1,84 @@
import { Minus, Plus } from 'lucide-react';
import { useRef } from 'react';
import {
type AriaNumberFieldProps,
useId,
useLocale,
useNumberField,
} from 'react-aria';
import { useNumberFieldState } from 'react-stately';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
import { Minus, Plus } from "lucide-react";
import { useRef } from "react";
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
import { useNumberFieldState } from "react-stately";
import IconButton from "~/components/IconButton";
import cn from "~/utils/cn";
export interface InputProps extends AriaNumberFieldProps {
isRequired?: boolean;
name?: string;
isRequired?: boolean;
name?: string;
}
export default function NumberInput(props: InputProps) {
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const { label, name } = props;
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
const {
labelProps,
inputProps,
groupProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
} = useNumberField(props, state, ref);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
>
{label}
</label>
<div
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
className="w-7.5 h-7.5 rounded-lg"
>
<Minus className="p-1" />
</IconButton>
<IconButton
{...incrementButtonProps}
label="Increment"
className="w-7.5 h-7.5 rounded-lg"
>
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
</div>
)}
</div>
);
return (
<div className="flex flex-col">
<label
{...labelProps}
htmlFor={id}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
>
{label}
</label>
<div
{...groupProps}
className={cn(
"flex items-center gap-1 rounded-md pr-1",
"focus-within:outline-hidden focus-within:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
)}
>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
id={id}
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton {...decrementButtonProps} label="Decrement" className="h-7.5 w-7.5 rounded-lg">
<Minus className="p-1" />
</IconButton>
<IconButton {...incrementButtonProps} label="Increment" className="h-7.5 w-7.5 rounded-lg">
<Plus className="p-1" />
</IconButton>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{isInvalid && (
<div
{...errorMessageProps}
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
>
{validationErrors.join(" ")}
</div>
)}
</div>
);
}
+47 -56
View File
@@ -1,78 +1,69 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn";
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn("flex flex-col", className)}>
<div {...tabListProps} ref={ref} className="flex items-center gap-2 overflow-x-scroll">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
"pl-0.5 pr-2 py-0.5 rounded-md cursor-pointer",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"focus:outline-hidden focus:ring-3 z-10",
"border border-mist-100 dark:border-mist-800",
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="mt-2 w-full">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });
+39 -43
View File
@@ -1,49 +1,45 @@
import React, { useRef } from 'react';
import {
type AriaPopoverProps,
DismissButton,
Overlay,
usePopover,
} from 'react-aria';
import type { OverlayTriggerState } from 'react-stately';
import cn from '~/utils/cn';
import React, { useRef } from "react";
import { type AriaPopoverProps, DismissButton, Overlay, usePopover } from "react-aria";
import type { OverlayTriggerState } from "react-stately";
export interface PopoverProps extends Omit<AriaPopoverProps, 'popoverRef'> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
import cn from "~/utils/cn";
export interface PopoverProps extends Omit<AriaPopoverProps, "popoverRef"> {
children: React.ReactNode;
state: OverlayTriggerState;
popoverRef?: React.RefObject<HTMLDivElement | null>;
className?: string;
}
export default function Popover(props: PopoverProps) {
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
const { state, children, className } = props;
const { popoverProps, underlayProps } = usePopover(
{
...props,
popoverRef: ref,
offset: 8,
},
state,
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-xs rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
return (
<Overlay>
<div {...underlayProps} className="fixed inset-0" />
<div
{...popoverProps}
ref={ref}
className={cn(
"z-10 shadow-overlay rounded-lg",
"bg-white dark:bg-mist-900",
"border border-mist-200 dark:border-mist-800",
className,
)}
>
<DismissButton onDismiss={state.close} />
{children}
<DismissButton onDismiss={state.close} />
</div>
</Overlay>
);
}
+20 -19
View File
@@ -1,26 +1,27 @@
import { useProgressBar } from 'react-aria';
import cn from '~/utils/cn';
import { useProgressBar } from "react-aria";
import cn from "~/utils/cn";
export interface ProgressBarProps {
isVisible: boolean;
isVisible: boolean;
}
export default function ProgressBar(props: ProgressBarProps) {
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: 'Loading...',
isIndeterminate: true,
});
const { isVisible } = props;
const { progressBarProps } = useProgressBar({
label: "Loading...",
isIndeterminate: true,
});
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
'fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0',
'bg-headplane-950 dark:bg-headplane-50',
isVisible && 'animate-loading opacity-100',
)}
/>
);
return (
<div
{...progressBarProps}
aria-hidden={!isVisible}
className={cn(
"fixed top-0 left-0 z-50 w-1/2 h-1 opacity-0",
"bg-mist-950 dark:bg-mist-50",
isVisible && "animate-loading opacity-100",
)}
/>
);
}
+57 -64
View File
@@ -1,83 +1,76 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import React, { createContext, useContext, useRef } from "react";
import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
import { useRadio, useRadioGroup } from "react-aria";
import { RadioGroupState } from "react-stately";
import { useRadioGroupState } from "react-stately";
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
import cn from "~/utils/cn";
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
"aria-label": label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
return (
<div {...radioGroupProps} className={cn("flex flex-col gap-2", className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
"aria-label": label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
"w-5 h-5 aspect-square rounded-full p-1 border-2",
"border border-mist-600 dark:border-mist-300",
isFocusVisible ? "ring-4" : "",
isDisabled ? "opacity-50 cursor-not-allowed" : "",
isSelected ? "border-[6px] border-mist-900 dark:border-mist-100" : "",
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
+143 -148
View File
@@ -1,174 +1,169 @@
import { Check, ChevronDown } from 'lucide-react';
import { useRef } from 'react';
import { Check, ChevronDown } from "lucide-react";
import { useRef } from "react";
import {
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from 'react-aria';
import { Item, ListState, Node, useComboBoxState } from 'react-stately';
import Popover from '~/components/Popover';
import cn from '~/utils/cn';
AriaComboBoxProps,
AriaListBoxOptions,
useButton,
useComboBox,
useFilter,
useId,
useListBox,
useOption,
} from "react-aria";
import { Item, ListState, Node, useComboBoxState } from "react-stately";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
export interface SelectProps extends AriaComboBoxProps<object> {
className?: string;
className?: string;
}
function Select(props: SelectProps) {
const { contains } = useFilter({ sensitivity: 'base' });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const { contains } = useFilter({ sensitivity: "base" });
const state = useComboBoxState({ ...props, defaultFilter: contains });
const id = useId(props.id);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listBoxRef = useRef<HTMLUListElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const {
buttonProps: triggerProps,
inputProps,
listBoxProps,
labelProps,
descriptionProps,
} = useComboBox(
{
...props,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
},
state,
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn(
'text-xs px-3 mt-1',
'text-headplane-500 dark:text-headplane-400',
)}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
const { buttonProps } = useButton(triggerProps, buttonRef);
return (
<div className={cn("flex flex-col", props.className)}>
<label
{...labelProps}
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
"flex rounded-md focus:outline-hidden focus-within:ring-3",
"bg-white dark:bg-mist-900",
"border border-mist-100 dark:border-mist-800",
props.isInvalid && "ring-red-400",
)}
>
<input
{...inputProps}
className="w-full rounded-l-md bg-transparent px-3 py-2 outline-hidden"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
className={cn(
"flex items-center justify-center p-1 rounded-lg m-1",
"bg-mist-100 dark:bg-mist-700/30 font-medium",
props.isDisabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
</div>
{props.description && (
<div
{...descriptionProps}
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
>
{props.description}
</div>
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
)}
</div>
);
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
return (
<ul
{...listBoxProps}
className="max-h-72 w-full overflow-auto pt-1 outline-hidden"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
}
interface OptionProps {
item: Node<unknown>;
state: ListState<unknown>;
item: Node<unknown>;
state: ListState<unknown>;
}
function Option({ item, state }: OptionProps) {
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
const ref = useRef<HTMLLIElement | null>(null);
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
{
key: item.key,
},
state,
ref,
);
return (
<li
{...optionProps}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-hidden select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
return (
<li
{...optionProps}
className={cn(
"flex items-center justify-between",
"py-2 px-3 mx-1 rounded-lg mb-1",
"focus:outline-hidden select-none",
isFocused || isSelected
? "bg-mist-100/50 dark:bg-mist-800"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800",
isDisabled && "text-mist-300 dark:text-mist-600",
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
</li>
);
}
export default Object.assign(Select, { Item });
+17 -22
View File
@@ -1,27 +1,22 @@
import cn from '~/utils/cn';
import cn from "~/utils/cn";
export interface StatusCircleProps {
isOnline: boolean;
className?: string;
isOnline: boolean;
className?: string;
}
export default function StatusCircle({
isOnline,
className,
}: StatusCircleProps) {
return (
<svg
className={cn(
isOnline
? 'text-green-600 dark:text-green-500'
: 'text-headplane-200 dark:text-headplane-800',
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? 'Online' : 'Offline'}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
export default function StatusCircle({ isOnline, className }: StatusCircleProps) {
return (
<svg
className={cn(
isOnline ? "text-green-600 dark:text-green-500" : "text-mist-200 dark:text-mist-800",
className,
)}
viewBox="0 0 24 24"
fill="currentColor"
>
<title>{isOnline ? "Online" : "Offline"}</title>
<circle cx="12" cy="12" r="8" />
</svg>
);
}
+48 -57
View File
@@ -1,64 +1,55 @@
import { useRef } from 'react';
import {
AriaSwitchProps,
VisuallyHidden,
useFocusRing,
useSwitch,
} from 'react-aria';
import { useToggleState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
import { useToggleState } from "react-stately";
import cn from "~/utils/cn";
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: string;
label: string;
className?: string;
switchClassName?: string;
}
export default function Switch(props: SwitchProps) {
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
'aria-label': props.label,
},
state,
ref,
);
const state = useToggleState(props);
const ref = useRef<HTMLInputElement | null>(null);
const { focusProps, isFocusVisible } = useFocusRing();
const { inputProps } = useSwitch(
{
...props,
"aria-label": props.label,
},
state,
ref,
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input
{...inputProps}
{...focusProps}
aria-label={props.label}
ref={ref}
/>
</VisuallyHidden>
<div
aria-hidden
className={cn(
'flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full',
'bg-headplane-300 dark:bg-headplane-700',
'border border-transparent dark:border-headplane-800',
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
props.className,
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-full',
props.switchClassName,
)}
/>
</div>
</label>
);
return (
<label className="flex items-center gap-x-2">
<VisuallyHidden elementType="span">
<input {...inputProps} {...focusProps} aria-label={props.label} ref={ref} />
</VisuallyHidden>
<div
aria-hidden
className={cn(
"flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full",
"bg-mist-300 dark:bg-mist-700",
"border border-transparent dark:border-mist-800",
state.isSelected && "bg-mist-900 dark:bg-mist-950",
isFocusVisible && "ring-2",
props.isDisabled && "opacity-50",
props.className,
)}
>
<span
className={cn(
"h-[18px] w-[18px] transform rounded-full",
"bg-white transition duration-50 ease-in-out",
"translate-x-0 group-selected:translate-x-full",
state.isSelected && "translate-x-full",
props.switchClassName,
)}
/>
</div>
</label>
);
}
+23 -26
View File
@@ -1,34 +1,31 @@
import type { HTMLProps } from 'react';
import cn from '~/utils/cn';
import type { HTMLProps } from "react";
import cn from "~/utils/cn";
function TableList(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
return (
<div
{...props}
className={cn("rounded-lg", "border border-mist-100 dark:border-mist-800", props.className)}
>
{props.children}
</div>
);
}
function Item(props: HTMLProps<HTMLDivElement>) {
return (
<div
{...props}
className={cn(
'flex items-center justify-between p-2 last:border-b-0',
'border-b border-headplane-100 dark:border-headplane-800',
props.className,
)}
>
{props.children}
</div>
);
return (
<div
{...props}
className={cn(
"flex items-center justify-between p-2 last:border-b-0",
"border-b border-mist-100 dark:border-mist-800",
props.className,
)}
>
{props.children}
</div>
);
}
export default Object.assign(TableList, { Item });
+63 -68
View File
@@ -1,90 +1,85 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn";
export interface TabsProps extends AriaTabListProps<object> {
label: string;
className?: string;
label: string;
className?: string;
}
function Tabs({ label, className, ...props }: TabsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
className={cn(
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn("flex flex-col", className)}>
<div
{...tabListProps}
className={cn(
"flex items-center rounded-t-lg w-fit max-w-full overflow-x-auto",
"border-mist-100 dark:border-mist-800",
"border-t border-x",
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface TabsTabProps {
item: Node<object>;
state: TabListState<object>;
item: Node<object>;
state: TabListState<object>;
}
function Tab({ item, state }: TabsTabProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{rendered}
</div>
);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
className={cn(
"pl-2 pr-3 py-2.5",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"focus:outline-hidden focus:ring-3 z-10",
"border-r border-mist-100 dark:border-mist-800",
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
)}
ref={ref}
>
{rendered}
</div>
);
}
export interface TabsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
state: TabListState<object>;
}
function TabsPanel({ state, ...props }: TabsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
);
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div
{...tabPanelProps}
className={cn(
"w-full overflow-clip rounded-b-lg rounded-r-lg",
"border border-mist-100 dark:border-mist-800",
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Tabs, { Item });
+52 -66
View File
@@ -1,87 +1,73 @@
import {
AriaToastProps,
AriaToastRegionProps,
useToast,
useToastRegion,
} from '@react-aria/toast';
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast';
import { X } from 'lucide-react';
import React, { useRef } from 'react';
import IconButton from '~/components/IconButton';
import cn from '~/utils/cn';
import { AriaToastProps, AriaToastRegionProps, useToast, useToastRegion } from "@react-aria/toast";
import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
import { X } from "lucide-react";
import React, { useRef } from "react";
import IconButton from "~/components/IconButton";
import cn from "~/utils/cn";
interface ToastProps extends AriaToastProps<React.ReactNode> {
state: ToastState<React.ReactNode>;
state: ToastState<React.ReactNode>;
}
function Toast({ state, ...props }: ToastProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(
props,
state,
ref,
);
const ref = useRef<HTMLDivElement | null>(null);
const { toastProps, contentProps, titleProps, closeButtonProps } = useToast(props, state, ref);
return (
<div
{...toastProps}
ref={ref}
className={cn(
'flex items-center justify-between gap-x-3 pl-4 pr-3',
'text-white shadow-lg dark:shadow-md rounded-xl py-3',
'bg-headplane-900 dark:bg-headplane-950',
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
'bg-transparent hover:bg-headplane-700',
'dark:bg-transparent dark:hover:bg-headplane-800',
)}
>
<X className="p-1" />
</IconButton>
</div>
);
return (
<div
{...toastProps}
ref={ref}
className={cn(
"flex items-center justify-between gap-x-3 pl-4 pr-3",
"text-white shadow-overlay rounded-lg py-3",
"bg-mist-900 dark:bg-mist-950",
)}
>
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
{...closeButtonProps}
label="Close"
className={cn(
"bg-transparent hover:bg-mist-700",
"dark:bg-transparent dark:hover:bg-mist-800",
)}
>
<X className="p-1" />
</IconButton>
</div>
);
}
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<React.ReactNode>;
state: ToastState<React.ReactNode>;
}
function ToastRegion({ state, ...props }: ToastRegionProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
const ref = useRef<HTMLDivElement | null>(null);
const { regionProps } = useToastRegion(props, state, ref);
return (
<div
{...regionProps}
ref={ref}
className={cn('fixed bottom-20 right-4', 'flex flex-col gap-4')}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
return (
<div
{...regionProps}
ref={ref}
className={cn("fixed bottom-20 right-4", "flex flex-col gap-4")}
>
{state.visibleToasts.map((toast) => (
<Toast key={toast.key} toast={toast} state={state} />
))}
</div>
);
}
export interface ToastProviderProps extends AriaToastRegionProps {
queue: ToastQueue<React.ReactNode>;
queue: ToastQueue<React.ReactNode>;
}
export default function ToastProvider({ queue, ...props }: ToastProviderProps) {
const state = useToastQueue(queue);
const state = useToastQueue(queue);
return (
<>
{state.visibleToasts.length > 0 && (
<ToastRegion {...props} state={state} />
)}
</>
);
return <>{state.visibleToasts.length > 0 && <ToastRegion {...props} state={state} />}</>;
}
+62 -66
View File
@@ -1,83 +1,79 @@
import React, { cloneElement, useRef } from 'react';
import {
AriaTooltipProps,
mergeProps,
useTooltip,
useTooltipTrigger,
} from 'react-aria';
import { TooltipTriggerState, useTooltipTriggerState } from 'react-stately';
import cn from '~/utils/cn';
import React, { cloneElement, useRef } from "react";
import { AriaTooltipProps, mergeProps, useTooltip, useTooltipTrigger } from "react-aria";
import { TooltipTriggerState, useTooltipTriggerState } from "react-stately";
import cn from "~/utils/cn";
export interface TooltipProps extends AriaTooltipProps {
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
children: [React.ReactElement, React.ReactElement<TooltipBodyProps>];
}
function Tooltip(props: TooltipProps) {
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const state = useTooltipTriggerState({
...props,
delay: 0,
closeDelay: 0,
});
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const ref = useRef<HTMLButtonElement | null>(null);
const { triggerProps, tooltipProps } = useTooltipTrigger(
{
...props,
delay: 0,
closeDelay: 0,
},
state,
ref,
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-hidden focus:ring-3 rounded-xl',
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
const [component, body] = props.children;
return (
<span className="relative">
<button
ref={ref}
{...triggerProps}
className={cn(
"flex items-center justify-center",
"focus:outline-hidden focus:ring-3 rounded-md",
)}
>
{component}
</button>
{state.isOpen &&
cloneElement(body, {
...tooltipProps,
state,
})}
</span>
);
}
interface TooltipBodyProps extends AriaTooltipProps {
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
children: React.ReactNode;
state?: TooltipTriggerState;
className?: string;
}
function Body({ state, className, ...props }: TooltipBodyProps) {
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-hidden rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
>
{props.children}
</span>
);
const { tooltipProps } = useTooltip(props, state);
return (
<span
{...mergeProps(props, tooltipProps)}
className={cn(
"absolute z-50 p-3 top-full mt-1",
"outline-hidden rounded-lg text-sm w-48",
"bg-white dark:bg-mist-950",
"text-black dark:text-white",
"shadow-overlay",
"border border-mist-100 dark:border-mist-800",
className,
)}
>
{props.children}
</span>
);
}
export default Object.assign(Tooltip, {
Body,
Body,
});
+175 -192
View File
@@ -1,211 +1,194 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
import { AlertCircle } from "lucide-react";
import { isRouteErrorResponse } from "react-router";
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Card from "./Card";
import Code from "./Code";
import Link from "./Link";
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Headscale API Error',
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: "Headscale API Error",
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of <strong>{statusCode}</strong>, indicating
a server-side issue. Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
const authError = error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
return {
title: "Invalid response from Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{" "}
The status code indicates an authentication error. Please verify your API key and
Headplane configuration.
</>
) : (
<> You may be using an unsupported version of Headscale or this may be a bug.</>
)}
</Card.Text>
<ul className="mt-2 list-inside list-disc">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{" "}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: "Cannot connect to Headscale API",
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your network setup and
configuration to ensure Headplane is able to connect.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
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
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
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 name="Headplane GitHub" to="https://github.com/tale/headplane/issues">
Headplane GitHub
</Link>{" "}
repository with the details below.
</Card.Text>
<Card.Text className="mt-4 text-lg font-semibold">Error Details</Card.Text>
<pre className="mt-2 overflow-x-auto rounded-lg bg-mist-100 p-2 dark:bg-mist-800">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error("AggregateError handling not implemented yet");
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
return {
title:
rootError.name.length > 0 && rootError.name !== "Error"
? `Error: ${rootError.name}`
: "Error",
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
return (
<Card className={cn("w-screen", className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
+29 -35
View File
@@ -1,42 +1,36 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
import Chip from "../Chip";
import Tooltip from "../Tooltip";
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
variant: "expired" | "no-expiry";
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
return (
<Tooltip>
<Chip
text={
variant === "expired" ? `Expired ${formatter.format(new Date(expiry!))}` : "No expiry"
}
className="bg-mist-200 text-mist-800 dark:bg-mist-800 dark:text-mist-200"
/>
<Tooltip.Body>
{variant === "expired" ? (
<>
This machine is expired and will not be able to connect to the network. Re-authenticate
with Tailscale on the machine to re-enable it.
</>
) : (
<>This machine has key expiry disabled and will never need to re-authenticate.</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+1 -1
View File
@@ -39,7 +39,7 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
export default function Layout() {
return (
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<main className="container mt-4 mb-24 overscroll-contain">
<Outlet />
</main>
);
+186
View File
@@ -0,0 +1,186 @@
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
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
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>
);
}
+3 -184
View File
@@ -1,17 +1,12 @@
import { Icon } from "@iconify/react";
import { Form, Outlet, redirect } from "react-router";
import { Outlet, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import Link from "~/components/Link";
import Options from "~/components/Options";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import { Route } from "./+types/shell";
import NoAccess from "./no-access";
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
@@ -115,183 +110,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{loaderData.linkedUserName ? (
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
<p className="text-sm">
Your account is linked to Headscale user{" "}
<strong>{loaderData.linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Access your network
<br />
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={loaderData.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="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
Click this button to copy the command.{" "}
<Link
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-headplane-600 dark:text-headplane-300 text-center text-sm">
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-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
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-headplane-600 dark:text-headplane-300 text-center text-sm">
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-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card className="max-w-lg" variant="flat">
<div className="flex h-full flex-col justify-between">
<div>
<Card.Title className="mb-4">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>
</div>
<Form action="/logout" className="mt-6" method="POST">
<Button className="w-full" type="submit" variant="light">
Sign out
</Button>
</Form>
</div>
</Card>
</div>
</main>
<NoAccess linkedUserName={loaderData.linkedUserName} osValue={loaderData.osValue} />
<Footer {...loaderData} />
</>
);
+1 -1
View File
@@ -39,7 +39,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
+30 -30
View File
@@ -1,36 +1,36 @@
import cn from '~/utils/cn';
import cn from "~/utils/cn";
interface Props {
readonly acl: string;
readonly acl: string;
}
export default function Fallback({ acl }: Props) {
return (
<div className="relative w-full h-editor flex">
<div
className={cn(
'h-full w-8 flex justify-center p-1',
'border-r border-headscale-400 dark:border-headscale-800',
)}
>
<div
aria-hidden
className={cn(
'h-5 w-5 animate-spin rounded-full',
'border-headplane-900 dark:border-headplane-100',
'border-2 border-t-transparent dark:border-t-transparent',
)}
/>
</div>
<textarea
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
readOnly
value={acl}
/>
</div>
);
return (
<div className="h-editor relative flex w-full">
<div
className={cn(
"h-full w-8 flex justify-center p-1",
"border-r border-mist-400 dark:border-mist-800",
)}
>
<div
aria-hidden
className={cn(
"h-5 w-5 animate-spin rounded-full",
"border-mist-900 dark:border-mist-100",
"border-2 border-t-transparent dark:border-t-transparent",
)}
/>
</div>
<textarea
className={cn(
"w-full h-editor font-mono resize-none text-sm",
"bg-mist-50 dark:bg-mist-950 opacity-60",
"pl-1 pt-1 leading-snug",
)}
readOnly
value={acl}
/>
</div>
);
}
+163 -186
View File
@@ -1,205 +1,182 @@
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import { closestCorners, DndContext, DragOverlay } from "@dnd-kit/core";
import { restrictToParentElement, restrictToVerticalAxis } from "@dnd-kit/modifiers";
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Form } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Lock } from "lucide-react";
import { useEffect, useState } from "react";
import { Form } from "react-router";
import Button from "~/components/Button";
import Input from "~/components/Input";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
interface Props {
searchDomains: string[];
isDisabled: boolean;
magic?: string;
searchDomains: string[];
isDisabled: boolean;
magic?: string;
}
export default function ManageDomains({
searchDomains,
isDisabled,
magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
export default function ManageDomains({ searchDomains, isDisabled, magic }: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
useEffect(() => {
setLocalDomains(searchDomains);
}, [searchDomains]);
return (
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
collisionDetection={closestCorners}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
return (
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS, your tailnet domain is
used as the first search domain.
</p>
<DndContext
collisionDetection={closestCorners}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
if (!over) {
return;
}
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
const activeItem = localDomains[(active.id as number) - 1];
const overItem = localDomains[(over.id as number) - 1];
if (!activeItem || !overItem) {
return;
}
if (!activeItem || !overItem) {
return;
}
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
const oldIndex = localDomains.indexOf(activeItem);
const newIndex = localDomains.indexOf(overItem);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
'flex items-center gap-4',
isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)}
>
<Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
domain={sd}
id={index + 1}
isDisabled={isDisabled}
key={sd}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
className="flex items-center justify-between w-full"
method="POST"
>
<input name="action_id" type="hidden" value="add_domain" />
<Input
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
type="submit"
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<TableList>
{magic ? (
<TableList.Item key="magic-dns-sd">
<div
className={cn(
"flex items-center gap-4",
isDisabled ? "flex-row-reverse justify-between w-full" : "",
)}
>
<Lock className="p-0.5" />
<p className="py-0.5 font-mono text-sm">{magic}</p>
</div>
</TableList.Item>
) : undefined}
<SortableContext items={localDomains} strategy={verticalListSortingStrategy}>
{localDomains.map((sd, index) => (
<Domain domain={sd} id={index + 1} isDisabled={isDisabled} key={sd} />
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
</SortableContext>
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form className="flex w-full items-center justify-between" method="POST">
<input name="action_id" type="hidden" value="add_domain" />
<Input
className={cn(
"border-none font-mono p-0 text-sm",
"rounded-none focus:ring-0 w-full ml-1",
)}
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
className={cn("px-2 py-1 rounded-md", "text-blue-500 dark:text-blue-400")}
type="submit"
>
Add
</Button>
</Form>
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
);
}
interface DomainProps {
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
domain: string;
id: number;
isDragging?: boolean;
isDisabled: boolean;
}
function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: isSortableDragging,
} = useSortable({ id });
return (
<TableList.Item
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="font-mono text-sm flex items-center gap-4">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<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>
);
return (
<TableList.Item
className={cn(
isSortableDragging ? "opacity-50" : "",
isDragging ? "ring-3 bg-white dark:bg-mist-900" : "",
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
}}
>
<p className="flex items-center gap-4 font-mono text-sm">
{isDisabled ? undefined : (
<GripVertical
{...attributes}
{...listeners}
className="rounded-md p-0.5 outline-hidden focus:ring-3"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<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>
);
}
+66 -70
View File
@@ -1,77 +1,73 @@
import { Form } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import AddRecord from '../dialogs/add-record';
import { Form } from "react-router";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Link from "~/components/Link";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
import AddRecord from "../dialogs/add-record";
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean;
records: { name: string; type: "A" | string; value: string }[];
isDisabled: boolean;
}
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<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
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-2 items-center w-full">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md text-center',
'bg-headplane-100 dark:bg-headplane-700/30 min-w-12',
)}
>
{record.type}
</p>
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
<p className="font-mono text-sm truncate">{record.name}</p>
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
{record.value}
</p>
</div>
</div>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_record" />
<input name="record_name" type="hidden" value={record.name} />
<input name="record_type" type="hidden" value={record.type} />
<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>
))
)}
</TableList>
return (
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">DNS Records</h1>
<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 name="Headscale DNS Records documentation" to="https://headscale.net/stable/ref/dns">
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0 ? (
<TableList.Item>
<p className="mx-auto opacity-50">No DNS records found</p>
</TableList.Item>
) : (
records.map((record) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex w-full items-center gap-2">
<p
className={cn(
"font-mono text-sm font-bold py-1 px-2 rounded-md text-center",
"bg-mist-100 dark:bg-mist-700/30 min-w-12",
)}
>
{record.type}
</p>
<div className="flex min-w-0 flex-1 flex-col sm:flex-row sm:gap-2">
<p className="truncate font-mono text-sm">{record.name}</p>
<p className="truncate font-mono text-sm opacity-70 sm:opacity-100">
{record.value}
</p>
</div>
</div>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_record" />
<input name="record_name" type="hidden" value={record.name} />
<input name="record_type" type="hidden" value={record.type} />
<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>
))
)}
</TableList>
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
{isDisabled ? undefined : <AddRecord records={records} />}
</div>
</div>
);
}
@@ -50,7 +50,7 @@ export default function MachineRow({
}, [magic, node.ipAddresses]);
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
@@ -122,7 +122,7 @@ export default function MachineRow({
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
<div>
<p
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
className={cn("text-sm", "text-mist-600 dark:text-mist-300")}
suppressHydrationWarning
>
{node.online && !node.expired
+174 -173
View File
@@ -1,183 +1,184 @@
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Menu from '~/components/Menu';
import type { User } from '~/types';
import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
import { useState } from "react";
import Button from "~/components/Button";
import Menu from "~/components/Menu";
import type { User } from "~/types";
import cn from "~/utils/cn";
import { PopulatedNode } from "~/utils/node-info";
import Delete from "../dialogs/delete";
import Expire from "../dialogs/expire";
import Move from "../dialogs/move";
import Rename from "../dialogs/rename";
import Routes from "../dialogs/routes";
import Tags from "../dialogs/tags";
interface MenuProps {
node: PopulatedNode;
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
node: PopulatedNode;
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
type Modal = "rename" | "expire" | "remove" | "routes" | "move" | "tags" | null;
export default function MachineMenu({
node,
magic,
users,
isFullButton,
isDisabled,
existingTags,
supportsNodeOwnerChange,
node,
magic,
users,
isFullButton,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
return (
<div className="flex items-center justify-end px-4 gap-1.5">
{modal === 'remove' && (
<Delete
isOpen={modal === 'remove'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
isOpen={modal === 'move'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
users={users}
/>
)}
{modal === 'rename' && (
<Rename
isOpen={modal === 'rename'}
machine={node}
magic={magic}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
isOpen={modal === 'routes'}
node={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
existingTags={existingTags}
isOpen={modal === 'tags'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{node.expired && modal === 'expire' ? undefined : (
<Expire
isOpen={modal === 'expire'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
return (
<div className="flex items-center justify-end gap-1.5 px-4">
{modal === "remove" && (
<Delete
isOpen={modal === "remove"}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === "move" && (
<Move
isOpen={modal === "move"}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
users={users}
/>
)}
{modal === "rename" && (
<Rename
isOpen={modal === "rename"}
machine={node}
magic={magic}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === "routes" && (
<Routes
isOpen={modal === "routes"}
node={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === "tags" && (
<Tags
existingTags={existingTags}
isOpen={modal === "tags"}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{node.expired && modal === "expire" ? undefined : (
<Expire
isOpen={modal === "expire"}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{supportsTailscaleSSH ? (
isFullButton ? (
<Button
className="flex items-center gap-x-2"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
variant="heavy"
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
SSH
</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={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
disabledKeys={node.expired ? ['expire'] : []}
onAction={(key) => setModal(key as Modal)}
>
<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>
</Menu>
</div>
);
{supportsTailscaleSSH ? (
isFullButton ? (
<Button
className="flex items-center gap-x-2"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
}}
variant="heavy"
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
className={cn(
"py-0.5 w-fit bg-transparent border-transparent",
"border group-hover:border-mist-200",
"dark:group-hover:border-mist-700",
"opacity-0 pointer-events-none group-hover:opacity-100",
"group-hover:pointer-events-auto",
)}
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
}}
>
SSH
</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={cn(
"py-0.5 w-10 bg-transparent border-transparent",
"border group-hover:border-mist-200",
"dark:group-hover:border-mist-700",
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
disabledKeys={node.expired ? ["expire"] : []}
onAction={(key) => setModal(key as Modal)}
>
<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>
</Menu>
</div>
);
}
+74 -80
View File
@@ -1,91 +1,85 @@
import { useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import type { Machine } from '~/types';
import { useState } from "react";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import type { Machine } from "~/types";
interface RenameProps {
machine: Machine;
isOpen: boolean;
magic?: string;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
isOpen: boolean;
magic?: string;
setIsOpen: (isOpen: boolean) => void;
}
export default function Rename({
machine,
magic,
isOpen,
setIsOpen,
}: RenameProps) {
const [name, setName] = useState(machine.givenName);
export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProps) {
const [name, setName] = useState(machine.givenName);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
<Dialog.Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
defaultValue={machine.givenName}
isRequired
label="Machine name"
name="name"
onChange={setName}
placeholder="Machine name"
validate={(value) => {
if (value.length === 0) {
return 'Cannot be empty';
}
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
<Dialog.Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used when generating
MagicDNS names.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
defaultValue={machine.givenName}
isRequired
label="Machine name"
name="name"
onChange={setName}
placeholder="Machine name"
validate={(value) => {
if (value.length === 0) {
return "Cannot be empty";
}
// DNS hostname validation
if (value.toLowerCase() !== value) {
return 'Cannot contain uppercase letters';
}
// DNS hostname validation
if (value.toLowerCase() !== value) {
return "Cannot contain uppercase letters";
}
if (value.length > 63) {
return 'DNS hostnames cannot be 64+ characters';
}
if (value.length > 63) {
return "DNS hostnames cannot be 64+ characters";
}
// Test for invalid characters
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Cannot contain special characters';
}
// Test for invalid characters
if (!/^[a-z0-9-]+$/.test(value)) {
return "Cannot contain special characters";
}
// Test for leading/trailing hyphens
if (value.startsWith('-') || value.endsWith('-')) {
return 'Cannot start or end with a hyphen';
}
// Test for leading/trailing hyphens
if (value.startsWith("-") || value.endsWith("-")) {
return "Cannot start or end with a hyphen";
}
// Test for consecutive hyphens
if (value.includes('--')) {
return 'Cannot contain consecutive hyphens';
}
}}
validationBehavior="native"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
This machine will be accessible by the hostname{' '}
<Code className="text-sm">
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
will no longer point to this machine.
</p>
) : (
<p className="text-sm text-headplane-600 dark:text-headplane-300 leading-tight mt-2">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
</Dialog.Panel>
</Dialog>
);
// Test for consecutive hyphens
if (value.includes("--")) {
return "Cannot contain consecutive hyphens";
}
}}
validationBehavior="native"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
This machine will be accessible by the hostname{" "}
<Code className="text-sm">{name.toLowerCase().replaceAll(/\s+/g, "-")}</Code>
{". "}
The hostname <Code className="text-sm">{machine.givenName}</Code> will no longer point
to this machine.
</p>
) : (
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
This machine is accessible by the hostname{" "}
<Code className="text-sm">{machine.givenName}</Code>.
</p>
)
) : undefined}
</Dialog.Panel>
</Dialog>
);
}
+7 -7
View File
@@ -84,7 +84,7 @@ export default function Page({
<div
className={cn(
"flex justify-between items-center pb-2",
"border-b border-headplane-100 dark:border-headplane-800",
"border-b border-mist-100 dark:border-mist-800",
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
@@ -101,8 +101,8 @@ export default function Page({
/>
</div>
<div className="mb-4 flex gap-1">
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
<div className="border-r border-mist-100 p-2 pr-4 dark:border-mist-800">
<span className="flex items-center gap-x-1 text-sm text-mist-600 dark:text-mist-300">
Managed by
<Tooltip>
<Info className="p-1" />
@@ -115,7 +115,7 @@ export default function Page({
</div>
</div>
<div className="p-2 pl-4">
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
<p className="text-sm text-mist-600 dark:text-mist-300">Status</p>
<div className="mt-1 mb-8 flex gap-1">
{mapTagsToComponents(node, uiTags)}
{tags.map((tag) => (
@@ -143,7 +143,7 @@ export default function Page({
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Approved
<Tooltip>
<Info className="h-3.5 w-3.5" />
@@ -171,7 +171,7 @@ export default function Page({
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Awaiting Approval
<Tooltip>
<Info className="h-3.5 w-3.5" />
@@ -200,7 +200,7 @@ export default function Page({
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
<span className="flex items-center gap-x-1 text-mist-600 dark:text-mist-300">
Exit Node
<Tooltip>
<Info className="h-3.5 w-3.5" />
+16 -13
View File
@@ -181,9 +181,9 @@ export default function Page({ loaderData }: Route.ComponentProps) {
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2",
"p-1 rounded-full",
"text-headplane-400 hover:text-headplane-600",
"dark:text-headplane-500 dark:hover:text-headplane-300",
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
"text-mist-400 hover:text-mist-600",
"dark:text-mist-500 dark:hover:text-mist-300",
"hover:bg-mist-100 dark:hover:bg-mist-800",
)}
onClick={() => setSearchQuery("")}
type="button"
@@ -192,7 +192,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</button>
)}
</div>
<span className="text-headplane-500 text-sm whitespace-nowrap">
<span className="text-sm whitespace-nowrap text-mist-500">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
@@ -200,7 +200,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<thead className="text-mist-600 dark:text-mist-300">
<tr className="px-0.5 text-left">
<th
aria-sort={
@@ -216,7 +216,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
aria-label="Sort by name"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("name")}
type="button"
@@ -245,7 +245,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
aria-label="Sort by IP address"
className={cn(
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
"hover:text-headplane-900 dark:hover:text-headplane-100",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("ip")}
type="button"
@@ -289,7 +289,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
aria-label="Sort by version"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("version")}
type="button"
@@ -318,7 +318,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
aria-label="Sort by last seen"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("lastSeen")}
type="button"
@@ -332,19 +332,22 @@ export default function Page({ loaderData }: Route.ComponentProps) {
))}
</button>
</th>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="text-headplane-500 py-8 text-center"
colSpan={loaderData.agent !== undefined ? 5 : 4}
className="py-8 text-center text-mist-500"
colSpan={loaderData.agent !== undefined ? 6 : 5}
>
No machines found matching "{searchQuery}"
</td>
@@ -89,7 +89,7 @@ export default function AddAuthKey({
<Dialog.Text>
Copy this key now. You will not be able to see the full key again.
</Dialog.Text>
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
<div className="mt-4 flex items-center gap-2 rounded-lg bg-mist-100 px-3 py-2 dark:bg-mist-800">
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
<Button
className="shrink-0"
+61 -76
View File
@@ -1,85 +1,70 @@
import { GlobeLock, Group, User2 } from 'lucide-react';
import React from 'react';
import { Form } from 'react-router';
import Button from '~/components/Button';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
import { GlobeLock, Group, User2 } from "lucide-react";
import React from "react";
import { Form } from "react-router";
import Button from "~/components/Button";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
interface RestrictionProps {
children: React.ReactNode;
type: 'domain' | 'group' | 'user';
values: string[];
isDisabled?: boolean;
children: React.ReactNode;
type: "domain" | "group" | "user";
values: string[];
isDisabled?: boolean;
}
export default function RestrictionTable({
children,
type,
values,
isDisabled,
}: RestrictionProps) {
return (
<div className="w-full sm:w-2/3">
<h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
<TableList className="my-4">
{values.length > 0 ? (
values.map((value) => (
<TableList.Item key={`${type}-${value}`}>
{type === 'domain' ? (
<p>
<span className="text-headplane-600 dark:text-headplane-300">
{'<user>'}
</span>
<span className="font-bold">@</span>
<span>{value}</span>
</p>
) : (
<p>{value}</p>
)}
<Form method="POST">
<input
name="action_id"
type="hidden"
value={`remove_${type}`}
/>
<input name={type} type="hidden" value={value} />
<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>
))
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="font-semibold text-center">
All {type}s are permitted to authenticate.
</p>
</TableList.Item>
)}
</TableList>
{children}
</div>
);
export default function RestrictionTable({ children, type, values, isDisabled }: RestrictionProps) {
return (
<div className="w-full sm:w-2/3">
<h2 className="mt-8 text-2xl font-medium">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
<TableList className="my-4">
{values.length > 0 ? (
values.map((value) => (
<TableList.Item key={`${type}-${value}`}>
{type === "domain" ? (
<p>
<span className="text-mist-600 dark:text-mist-300">{"<user>"}</span>
<span className="font-bold">@</span>
<span>{value}</span>
</p>
) : (
<p>{value}</p>
)}
<Form method="POST">
<input name="action_id" type="hidden" value={`remove_${type}`} />
<input name={type} type="hidden" value={value} />
<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>
))
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="text-center font-semibold">All {type}s are permitted to authenticate.</p>
</TableList.Item>
)}
</TableList>
{children}
</div>
);
}
function iconForType(type: 'domain' | 'group' | 'user') {
if (type === 'domain') {
return <GlobeLock />;
}
function iconForType(type: "domain" | "group" | "user") {
if (type === "domain") {
return <GlobeLock />;
}
if (type === 'group') {
return <Group />;
}
if (type === "group") {
return <Group />;
}
return <User2 />;
return <User2 />;
}
+2 -2
View File
@@ -256,10 +256,10 @@ export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.C
}
return (
<div className="bg-headplane-900 h-screen w-screen">
<div className="h-screen w-screen bg-mist-900">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="text-headplane-50 size-10 animate-spin" />
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : (
<div className="flex h-screen flex-col">
+179 -187
View File
@@ -1,226 +1,218 @@
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { FitAddon } from '@xterm/addon-fit';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebLinksAddon } from '@xterm/addon-web-links';
import * as xterm from '@xterm/xterm';
import { Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import toast from '~/utils/toast';
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import * as xterm from "@xterm/xterm";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import '@xterm/xterm/css/xterm.css';
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import toast from "~/utils/toast";
import "@xterm/xterm/css/xterm.css";
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === 'object') {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (data && typeof data === "object") {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (
any.buffer instanceof ArrayBuffer &&
typeof any.byteLength === 'number'
) {
return new Uint8Array(
any.buffer.slice(
any.byteOffset ?? 0,
(any.byteOffset ?? 0) + any.byteLength,
),
);
}
}
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
return new Uint8Array(
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
);
}
}
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, '_blank', 'noopener noreferrer');
}),
);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, "_blank", "noopener noreferrer");
}),
);
term.unicode.activeVersion = '11';
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
term.unicode.activeVersion = "11";
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stdout');
return;
}
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stdout");
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stderr');
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stderr");
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onConnect');
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onConnect");
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onDisconnect');
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onDisconnect");
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onData');
return;
}
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onData");
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring resize');
return;
}
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring resize");
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute w-screen z-50 mx-auto h-screen flex items-center justify-center">
<Loader2 className="animate-spin size-10 text-headplane-50" />
</div>
) : undefined}
<div
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
ref={divRef}
/>
{termRef.current && isResizing ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
return (
<>
{isLoading ? (
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : undefined}
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
{termRef.current && isResizing ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
+59 -63
View File
@@ -1,69 +1,65 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import CreateUser from '../dialogs/create-user';
import { Building2, House, Key } from "lucide-react";
import Card from "~/components/Card";
import Link from "~/components/Link";
import CreateUser from "../dialogs/create-user";
interface ManageBannerProps {
oidc?: { issuer: string };
isDisabled?: boolean;
oidc?: { issuer: string };
isDisabled?: boolean;
}
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
{oidc ? (
<Building2 className="w-5 h-5 mb-2" />
) : (
<House className="w-5 h-5 mb-2" />
)}
<h2 className="font-medium mb-1">
{oidc ? 'OpenID Connect' : 'User Authentication'}
</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
{oidc ? (
<>
Users are managed through your{' '}
<Link name="OIDC Provider" 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>
</>
) : (
<>
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>
</>
)}
</p>
</div>
<div className="w-full p-4 md:border-l border-headplane-100 dark:border-headplane-800">
<Key className="w-5 h-5 mb-2" />
<h2 className="font-medium mb-1">User Management</h2>
<p className="text-sm text-headplane-600 dark:text-headplane-300">
{oidc
? 'You can still add users manually, however it is recommended that you manage users through your OIDC provider.'
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
</div>
</Card>
);
return (
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<div className="flex flex-col md:flex-row">
<div className="w-full border-b border-mist-100 p-4 md:border-b-0 dark:border-mist-800">
{oidc ? <Building2 className="mb-2 h-5 w-5" /> : <House className="mb-2 h-5 w-5" />}
<h2 className="mb-1 font-medium">{oidc ? "OpenID Connect" : "User Authentication"}</h2>
<p className="text-sm text-mist-600 dark:text-mist-300">
{oidc ? (
<>
Users are managed through your{" "}
<Link name="OIDC Provider" 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>
</>
) : (
<>
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>
</>
)}
</p>
</div>
<div className="w-full border-mist-100 p-4 md:border-l dark:border-mist-800">
<Key className="mb-2 h-5 w-5" />
<h2 className="mb-1 font-medium">User Management</h2>
<p className="text-sm text-mist-600 dark:text-mist-300">
{oidc
? "You can still add users manually, however it is recommended that you manage users through your OIDC provider."
: "You can add, remove, and rename users here."}
</p>
<div className="mt-4 flex items-center gap-2">
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
</div>
</Card>
);
}
+2 -2
View File
@@ -79,8 +79,8 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp
<Menu.IconButton
className={cn(
"w-10 border-transparent bg-transparent py-0.5",
"border group-hover:border-headplane-200",
"dark:group-hover:border-headplane-700",
"border group-hover:border-mist-200",
"dark:group-hover:border-mist-700",
)}
label="User Options"
>
+3 -6
View File
@@ -21,7 +21,7 @@ export default function UserRow({ user, role, headscaleUsers, currentLink }: Use
);
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
@@ -43,16 +43,13 @@ export default function UserRow({ user, role, headscaleUsers, currentLink }: Use
<p>{mapRoleToName(role)}</p>
</td>
<td className="py-2 pl-0.5">
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
<p className="text-sm text-mist-600 dark:text-mist-300" suppressHydrationWarning>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="py-2 pl-0.5">
<span
className={cn(
"flex items-center gap-x-1 text-sm",
"text-headplane-600 dark:text-headplane-300",
)}
className={cn("flex items-center gap-x-1 text-sm", "text-mist-600 dark:text-mist-300")}
>
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
+31 -33
View File
@@ -1,40 +1,38 @@
import Dialog from '~/components/Dialog';
import { Machine, User } from '~/types';
import Dialog from "~/components/Dialog";
import { Machine, User } from "~/types";
interface DeleteProps {
user: User & { machines: Machine[] };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: User & { machines: Machine[] };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
const name = user.name || user.displayName;
const name = user.name || user.displayName;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.machines.length > 0 ? 'unactionable' : 'normal'}
>
<Dialog.Title>Delete {name}?</Dialog.Title>
{user.machines.length > 0 ? (
<Dialog.Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or
re-assign their machines to other users before proceeding.
</Dialog.Text>
) : (
<Dialog.Text className="mb-6">
Deleted users cannot be recovered.
{user.provider === 'oidc' && (
<p className="mt-4 text-sm text-headplane-600 dark:text-headplane-300">
Since this user is authenticated via an external provider, they
will be recreated if they sign in again.
</p>
)}
</Dialog.Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Title>Delete {name}?</Dialog.Title>
{user.machines.length > 0 ? (
<Dialog.Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or re-assign their machines
to other users before proceeding.
</Dialog.Text>
) : (
<Dialog.Text className="mb-6">
Deleted users cannot be recovered.
{user.provider === "oidc" && (
<p className="mt-4 text-sm text-mist-600 dark:text-mist-300">
Since this user is authenticated via an external provider, they will be recreated if
they sign in again.
</p>
)}
</Dialog.Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</Dialog>
);
}
+2 -2
View File
@@ -35,8 +35,8 @@ export default function LinkUser({
<select
className={cn(
"w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
"border-mist-200 dark:border-mist-700",
"bg-mist-50 dark:bg-mist-900",
)}
defaultValue={currentLink ?? ""}
name="headscale_user_id"
+11 -13
View File
@@ -144,8 +144,8 @@ export default function Page({
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
"border-mist-200 dark:border-mist-700",
"bg-mist-50 dark:bg-mist-900",
)}
name="headscale_user_id"
required
@@ -167,7 +167,7 @@ export default function Page({
Skip — I'll do this later
</Button>
</NavLink>
<p className="text-headplane-500 mt-2 text-center text-xs">
<p className="mt-2 text-center text-xs text-mist-500">
Without linking, you won't be able to see your own machines or generate pre-auth keys.
An admin can link your account later from the Users page.
</p>
@@ -213,7 +213,7 @@ export default function Page({
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
Click this button to copy the command.{" "}
<Link
name="Linux installation script"
@@ -242,7 +242,7 @@ export default function Page({
Download for Windows
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Windows 10 or later.
</p>
</Options.Item>
@@ -265,7 +265,7 @@ export default function Page({
Download for macOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
<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{" "}
@@ -297,7 +297,7 @@ export default function Page({
Download for iOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires iOS 15 or later.
</p>
</Options.Item>
@@ -320,7 +320,7 @@ export default function Page({
Download for Android
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
<p className="text-center text-sm text-mist-600 dark:text-mist-300">
Requires Android 8 or later.
</p>
</Options.Item>
@@ -334,7 +334,7 @@ export default function Page({
<br />
We found your first device
</Card.Title>
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
<div className="rounded-xl border border-mist-100 p-4 dark:border-mist-800">
<div className="flex items-start gap-4">
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
<div>
@@ -364,12 +364,10 @@ export default function Page({
className={cn(
"absolute inline-flex h-full w-full",
"rounded-full opacity-75 animate-ping",
"bg-headplane-500",
"bg-mist-500",
)}
/>
<span
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
/>
<span className={cn("relative inline-flex size-4 rounded-full", "bg-mist-400")} />
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
+6 -3
View File
@@ -134,18 +134,21 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<thead className="text-mist-600 dark:text-mist-300">
<tr className="px-0.5 text-left">
<th className="pb-2 text-xs font-bold uppercase">User</th>
<th className="pb-2 text-xs font-bold uppercase">Role</th>
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{users
+7 -34
View File
@@ -13,41 +13,14 @@
--transition-duration-25: 25ms;
--transition-duration-50: 50ms;
--color-main-50: #f8fafc;
--color-main-100: #f1f5f9;
--color-main-200: #e2e8f0;
--color-main-300: #cbd5e1;
--color-main-400: #94a3b8;
--color-main-500: #64748b;
--color-main-600: #475569;
--color-main-700: #334155;
--color-main-800: #1e293b;
--color-main-900: #0f172a;
--color-main-950: #020617;
/* Design tokens: border-radius */
--radius-sm: 0.375rem; /* 6px — chips, badges, inline elements */
--radius-md: 0.5rem; /* 8px — buttons, inputs, interactive controls */
--radius-lg: 0.75rem; /* 12px — cards, panels, dialogs, popovers */
--color-ui-50: #fafafa;
--color-ui-100: #f5f5f5;
--color-ui-200: #e5e5e5;
--color-ui-300: #d4d4d4;
--color-ui-400: #a3a3a3;
--color-ui-500: #737373;
--color-ui-600: #525252;
--color-ui-700: #404040;
--color-ui-800: #262626;
--color-ui-900: #171717;
--color-ui-950: #0a0a0a;
--color-headplane-50: #f2f2f2;
--color-headplane-100: #e6e6e6;
--color-headplane-200: #cccccc;
--color-headplane-300: #b3b3b3;
--color-headplane-400: #999999;
--color-headplane-500: #808080;
--color-headplane-600: #666666;
--color-headplane-700: #4d4d4d;
--color-headplane-800: #343434;
--color-headplane-900: #1a1a1a;
--color-headplane-950: #0d0d0d;
/* Design tokens: box-shadow */
--shadow-surface: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 1px 3px 0 rgb(0 0 0 / 0.06);
--shadow-overlay: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 2px 6px -1px rgb(0 0 0 / 0.06);
--animate-loading: loader 0.8s infinite ease-in-out;