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