From 7403ebea0684932bd9e9e5d5f6639120aa608201 Mon Sep 17 00:00:00 2001
From: Aarnav Tale
Date: Tue, 17 Mar 2026 15:19:22 -0400
Subject: [PATCH] feat: switch form fields to base-ui
---
app/components/Input.tsx | 84 ---------
app/components/Notice.tsx | 45 -----
app/components/NumberInput.tsx | 99 ----------
app/components/Popover.tsx | 45 -----
app/components/RadioGroup.tsx | 78 --------
app/components/Select.tsx | 170 ------------------
app/components/Switch.tsx | 56 ------
app/components/Text.tsx | 11 --
app/components/Title.tsx | 13 --
.../{Attribute.tsx => attribute.tsx} | 2 +-
app/components/{Card.tsx => card.tsx} | 4 +-
app/components/{Chip.tsx => chip.tsx} | 0
app/components/{Code.tsx => code.tsx} | 0
app/components/{Dialog.tsx => dialog.tsx} | 0
app/components/error-banner.tsx | 4 +-
app/components/input.tsx | 72 ++++++++
app/components/notice.tsx | 34 ++++
app/components/number-input.tsx | 68 +++++++
app/components/radio-group.tsx | 60 +++++++
app/components/select.tsx | 138 ++++++++++++++
.../{StatusCircle.tsx => status-circle.tsx} | 0
app/components/switch.tsx | 45 +++++
.../{TableList.tsx => table-list.tsx} | 0
app/components/{Tabs.tsx => tabs.tsx} | 0
app/components/tags/ExitNode.tsx | 52 +++---
app/components/tags/Expiry.tsx | 4 +-
app/components/tags/HeadplaneAgent.tsx | 33 ++--
app/components/tags/Subnet.tsx | 52 +++---
app/components/tags/TailscaleSSH.tsx | 34 ++--
app/components/text.tsx | 12 ++
app/components/title.tsx | 12 ++
app/components/{Tooltip.tsx => tooltip.tsx} | 0
app/layout/link-account.tsx | 2 +-
app/routes/acls/overview.tsx | 8 +-
app/routes/auth/login/config-error.tsx | 4 +-
app/routes/auth/login/logout.tsx | 23 ++-
app/routes/auth/login/oidc-error.tsx | 103 ++++++-----
app/routes/auth/login/page.tsx | 8 +-
app/routes/dns/components/manage-domains.tsx | 6 +-
app/routes/dns/components/manage-ns.tsx | 10 +-
app/routes/dns/components/manage-records.tsx | 4 +-
app/routes/dns/components/rename-tailnet.tsx | 16 +-
app/routes/dns/components/toggle-magic.tsx | 6 +-
app/routes/dns/dialogs/add-ns.tsx | 22 +--
app/routes/dns/dialogs/add-record.tsx | 37 ++--
app/routes/dns/overview.tsx | 4 +-
app/routes/home.tsx | 2 +-
.../machines/components/machine-row.tsx | 4 +-
app/routes/machines/dialogs/delete.tsx | 6 +-
app/routes/machines/dialogs/expire.tsx | 6 +-
app/routes/machines/dialogs/move.tsx | 28 +--
app/routes/machines/dialogs/new.tsx | 32 ++--
app/routes/machines/dialogs/rename.tsx | 42 +----
app/routes/machines/dialogs/routes.tsx | 18 +-
app/routes/machines/dialogs/tags.tsx | 32 ++--
app/routes/machines/machine.tsx | 10 +-
app/routes/machines/overview.tsx | 6 +-
.../settings/auth-keys/auth-key-row.tsx | 2 +-
.../auth-keys/dialogs/add-auth-key.tsx | 62 +++----
.../auth-keys/dialogs/expire-auth-key.tsx | 6 +-
app/routes/settings/auth-keys/overview.tsx | 48 +++--
.../restrictions/dialogs/add-domain.tsx | 12 +-
.../restrictions/dialogs/add-group.tsx | 12 +-
.../restrictions/dialogs/add-user.tsx | 12 +-
app/routes/settings/restrictions/overview.tsx | 2 +-
app/routes/settings/restrictions/table.tsx | 2 +-
app/routes/ssh/user-prompt.tsx | 6 +-
.../users/components/headplane-user-row.tsx | 2 +-
.../users/components/headscale-user-row.tsx | 2 +-
app/routes/users/dialogs/create-user.tsx | 44 +----
app/routes/users/dialogs/delete-user.tsx | 6 +-
app/routes/users/dialogs/link-user.tsx | 8 +-
app/routes/users/dialogs/reassign-user.tsx | 18 +-
app/routes/users/dialogs/rename-user.tsx | 10 +-
.../users/dialogs/transfer-ownership.tsx | 8 +-
75 files changed, 842 insertions(+), 1086 deletions(-)
delete mode 100644 app/components/Input.tsx
delete mode 100644 app/components/Notice.tsx
delete mode 100644 app/components/NumberInput.tsx
delete mode 100644 app/components/Popover.tsx
delete mode 100644 app/components/RadioGroup.tsx
delete mode 100644 app/components/Select.tsx
delete mode 100644 app/components/Switch.tsx
delete mode 100644 app/components/Text.tsx
delete mode 100644 app/components/Title.tsx
rename app/components/{Attribute.tsx => attribute.tsx} (98%)
rename app/components/{Card.tsx => card.tsx} (88%)
rename app/components/{Chip.tsx => chip.tsx} (100%)
rename app/components/{Code.tsx => code.tsx} (100%)
rename app/components/{Dialog.tsx => dialog.tsx} (100%)
create mode 100644 app/components/input.tsx
create mode 100644 app/components/notice.tsx
create mode 100644 app/components/number-input.tsx
create mode 100644 app/components/radio-group.tsx
create mode 100644 app/components/select.tsx
rename app/components/{StatusCircle.tsx => status-circle.tsx} (100%)
create mode 100644 app/components/switch.tsx
rename app/components/{TableList.tsx => table-list.tsx} (100%)
rename app/components/{Tabs.tsx => tabs.tsx} (100%)
create mode 100644 app/components/text.tsx
create mode 100644 app/components/title.tsx
rename app/components/{Tooltip.tsx => tooltip.tsx} (100%)
diff --git a/app/components/Input.tsx b/app/components/Input.tsx
deleted file mode 100644
index bbb608a..0000000
--- a/app/components/Input.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Asterisk } from "lucide-react";
-import { useRef } from "react";
-import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
-
-import cn from "~/utils/cn";
-
-export interface InputProps extends AriaTextFieldProps {
- label: string;
- labelHidden?: boolean;
- isRequired?: boolean;
- className?: string;
- isInvalid?: boolean;
- errorMessage?: string;
-}
-
-export default function Input(props: InputProps) {
- const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
- const ref = useRef(null);
- const id = useId(props.id);
-
- const {
- labelProps,
- inputProps,
- descriptionProps,
- errorMessageProps,
- isInvalid: ariaIsInvalid,
- validationErrors,
- } = useTextField(
- {
- ...props,
- label,
- "aria-label": label,
- },
- ref,
- );
-
- const isInvalid = customIsInvalid ?? ariaIsInvalid;
-
- return (
-
-
- {label}
- {props.isRequired && }
-
-
- {props.description && (
-
- {props.description}
-
- )}
- {isInvalid ? (
-
- {errorMessage ?? validationErrors.join(" ")}
-
- ) : null}
-
- );
-}
diff --git a/app/components/Notice.tsx b/app/components/Notice.tsx
deleted file mode 100644
index 9200019..0000000
--- a/app/components/Notice.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import {
- CircleAlert,
- CircleSlash2,
- LucideProps,
- TriangleAlert,
-} from 'lucide-react';
-import React from 'react';
-import Card from '~/components/Card';
-
-export interface NoticeProps {
- children: React.ReactNode;
- title?: string;
- variant?: 'default' | 'error' | 'warning';
- icon?: React.ReactElement;
-}
-
-export default function Notice({
- children,
- title,
- variant,
- icon,
-}: NoticeProps) {
- return (
-
-
- {title ? (
- {title}
- ) : undefined}
- {!variant && icon ? icon : iconForVariant(variant)}
-
- {children}
-
- );
-}
-
-function iconForVariant(variant?: 'default' | 'error' | 'warning') {
- switch (variant) {
- case 'error':
- return ;
- case 'warning':
- return ;
- default:
- return ;
- }
-}
diff --git a/app/components/NumberInput.tsx b/app/components/NumberInput.tsx
deleted file mode 100644
index f520afd..0000000
--- a/app/components/NumberInput.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { Minus, Plus } from "lucide-react";
-import { useRef } from "react";
-import { type AriaNumberFieldProps, useButton, useId, useLocale, useNumberField } from "react-aria";
-import { useNumberFieldState } from "react-stately";
-
-import cn from "~/utils/cn";
-
-export interface InputProps extends AriaNumberFieldProps {
- isRequired?: boolean;
- name?: string;
-}
-
-export default function NumberInput(props: InputProps) {
- const { label, name } = props;
- const { locale } = useLocale();
- const state = useNumberFieldState({ ...props, locale });
- const ref = useRef(null);
- const id = useId(props.id);
-
- const {
- labelProps,
- inputProps,
- groupProps,
- incrementButtonProps,
- decrementButtonProps,
- descriptionProps,
- errorMessageProps,
- isInvalid,
- validationErrors,
- } = useNumberField(props, state, ref);
-
- const decrRef = useRef(null);
- const incrRef = useRef(null);
- const { buttonProps: decrProps } = useButton(decrementButtonProps, decrRef);
- const { buttonProps: incrProps } = useButton(incrementButtonProps, incrRef);
-
- return (
-
-
- {label}
-
-
- {props.description && (
-
- {props.description}
-
- )}
- {isInvalid && (
-
- {validationErrors.join(" ")}
-
- )}
-
- );
-}
diff --git a/app/components/Popover.tsx b/app/components/Popover.tsx
deleted file mode 100644
index 9a808f9..0000000
--- a/app/components/Popover.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import React, { useRef } from "react";
-import { type AriaPopoverProps, DismissButton, Overlay, usePopover } from "react-aria";
-import type { OverlayTriggerState } from "react-stately";
-
-import cn from "~/utils/cn";
-
-export interface PopoverProps extends Omit {
- children: React.ReactNode;
- state: OverlayTriggerState;
- popoverRef?: React.RefObject;
- className?: string;
-}
-
-export default function Popover(props: PopoverProps) {
- const ref = props.popoverRef ?? useRef(null);
- const { state, children, className } = props;
- const { popoverProps, underlayProps } = usePopover(
- {
- ...props,
- popoverRef: ref,
- offset: 8,
- },
- state,
- );
-
- return (
-
-
-
-
- {children}
-
-
-
- );
-}
diff --git a/app/components/RadioGroup.tsx b/app/components/RadioGroup.tsx
deleted file mode 100644
index 67d2643..0000000
--- a/app/components/RadioGroup.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import React, { createContext, useContext, useRef } from "react";
-import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
-import { useRadio, useRadioGroup } from "react-aria";
-import { RadioGroupState } from "react-stately";
-import { useRadioGroupState } from "react-stately";
-
-import cn from "~/utils/cn";
-
-interface RadioGroupProps extends AriaRadioGroupProps {
- children: React.ReactElement[];
- label: string;
- className?: string;
-}
-
-const RadioContext = createContext(null);
-
-function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
- const state = useRadioGroupState(props);
- const { radioGroupProps, labelProps } = useRadioGroup(
- {
- ...props,
- "aria-label": label,
- },
- state,
- );
-
- return (
-
-
- {label}
-
- {children}
-
- );
-}
-
-interface RadioProps extends AriaRadioProps {
- label: string;
- className?: string;
-}
-
-function Radio({ children, label, className, ...props }: RadioProps) {
- const state = useContext(RadioContext);
- const ref = useRef(null);
- const { inputProps, isSelected, isDisabled } = useRadio(
- {
- ...props,
- "aria-label": label,
- },
- state!,
- ref,
- );
- const { isFocusVisible, focusProps } = useFocusRing();
-
- return (
-
-
-
-
-
- {children}
-
- );
-}
-
-export default Object.assign(RadioGroup, { Radio });
diff --git a/app/components/Select.tsx b/app/components/Select.tsx
deleted file mode 100644
index bd03844..0000000
--- a/app/components/Select.tsx
+++ /dev/null
@@ -1,170 +0,0 @@
-import { Check, ChevronDown } from "lucide-react";
-import { useRef } from "react";
-import {
- AriaComboBoxProps,
- AriaListBoxOptions,
- useButton,
- useComboBox,
- useFilter,
- useId,
- useListBox,
- useOption,
-} from "react-aria";
-import { Item, ListState, Node, useComboBoxState } from "react-stately";
-
-import Popover from "~/components/Popover";
-import cn from "~/utils/cn";
-
-export interface SelectProps extends AriaComboBoxProps {
- className?: string;
-}
-
-function Select(props: SelectProps) {
- const { contains } = useFilter({ sensitivity: "base" });
- const state = useComboBoxState({ ...props, defaultFilter: contains });
- const id = useId(props.id);
-
- const buttonRef = useRef(null);
- const inputRef = useRef(null);
- const listBoxRef = useRef(null);
- const popoverRef = useRef(null);
-
- const {
- buttonProps: triggerProps,
- inputProps,
- listBoxProps,
- labelProps,
- descriptionProps,
- } = useComboBox(
- {
- ...props,
- inputRef,
- buttonRef,
- listBoxRef,
- popoverRef,
- },
- state,
- );
-
- const { buttonProps } = useButton(triggerProps, buttonRef);
- return (
-
-
- {props.label}
-
-
-
-
-
-
-
- {props.description && (
-
- {props.description}
-
- )}
- {state.isOpen && (
-
-
-
- )}
-
- );
-}
-
-interface ListBoxProps extends AriaListBoxOptions {
- listBoxRef: React.RefObject;
- state: ListState;
-}
-
-function ListBox(props: ListBoxProps) {
- const { listBoxRef, state } = props;
- const { listBoxProps } = useListBox(props, state, listBoxRef);
-
- return (
-
- {[...state.collection].map((item) => (
-
- ))}
-
- );
-}
-
-interface OptionProps {
- item: Node;
- state: ListState;
-}
-
-function Option({ item, state }: OptionProps) {
- const ref = useRef(null);
- const { optionProps, isDisabled, isSelected, isFocused } = useOption(
- {
- key: item.key,
- },
- state,
- ref,
- );
-
- return (
-
- {item.rendered}
- {isSelected && }
-
- );
-}
-
-export default Object.assign(Select, { Item });
diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx
deleted file mode 100644
index 1a892fa..0000000
--- a/app/components/Switch.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { useRef } from "react";
-import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
-import { useToggleState } from "react-stately";
-
-import cn from "~/utils/cn";
-
-export interface SwitchProps extends AriaSwitchProps {
- label: string;
- className?: string;
- switchClassName?: string;
-}
-
-export default function Switch(props: SwitchProps) {
- const state = useToggleState(props);
- const ref = useRef(null);
- const { focusProps, isFocusVisible } = useFocusRing();
- const { inputProps } = useSwitch(
- {
- ...props,
- "aria-label": props.label,
- },
- state,
- ref,
- );
-
- return (
-
-
-
-
-
-
-
-
- );
-}
diff --git a/app/components/Text.tsx b/app/components/Text.tsx
deleted file mode 100644
index ae5f315..0000000
--- a/app/components/Text.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import React from 'react';
-import cn from '~/utils/cn';
-
-export interface TextProps {
- children: React.ReactNode;
- className?: string;
-}
-
-export default function Text({ children, className }: TextProps) {
- return {children}
;
-}
diff --git a/app/components/Title.tsx b/app/components/Title.tsx
deleted file mode 100644
index 969b354..0000000
--- a/app/components/Title.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-import cn from '~/utils/cn';
-
-export interface TitleProps {
- children: React.ReactNode;
- className?: string;
-}
-
-export default function Title({ children, className }: TitleProps) {
- return (
- {children}
- );
-}
diff --git a/app/components/Attribute.tsx b/app/components/attribute.tsx
similarity index 98%
rename from app/components/Attribute.tsx
rename to app/components/attribute.tsx
index 356c343..41512f6 100644
--- a/app/components/Attribute.tsx
+++ b/app/components/attribute.tsx
@@ -3,7 +3,7 @@ import { Check, Copy, Info } from "lucide-react";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
-import Tooltip from "./Tooltip";
+import Tooltip from "./tooltip";
export interface AttributeProps {
name: string;
diff --git a/app/components/Card.tsx b/app/components/card.tsx
similarity index 88%
rename from app/components/Card.tsx
rename to app/components/card.tsx
index 60a8178..cc990e0 100644
--- a/app/components/Card.tsx
+++ b/app/components/card.tsx
@@ -1,7 +1,7 @@
import React from "react";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Text from "~/components/text";
+import Title from "~/components/title";
import cn from "~/utils/cn";
interface Props extends React.HTMLProps {
diff --git a/app/components/Chip.tsx b/app/components/chip.tsx
similarity index 100%
rename from app/components/Chip.tsx
rename to app/components/chip.tsx
diff --git a/app/components/Code.tsx b/app/components/code.tsx
similarity index 100%
rename from app/components/Code.tsx
rename to app/components/code.tsx
diff --git a/app/components/Dialog.tsx b/app/components/dialog.tsx
similarity index 100%
rename from app/components/Dialog.tsx
rename to app/components/dialog.tsx
diff --git a/app/components/error-banner.tsx b/app/components/error-banner.tsx
index d096627..565fdad 100644
--- a/app/components/error-banner.tsx
+++ b/app/components/error-banner.tsx
@@ -4,8 +4,8 @@ import { isRouteErrorResponse } from "react-router";
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
-import Card from "./Card";
-import Code from "./Code";
+import Card from "./card";
+import Code from "./code";
import Link from "./link";
export function getErrorMessage(error: Error | unknown): {
diff --git a/app/components/input.tsx b/app/components/input.tsx
new file mode 100644
index 0000000..6be5ad9
--- /dev/null
+++ b/app/components/input.tsx
@@ -0,0 +1,72 @@
+import { Field } from "@base-ui/react/field";
+import { Input as BaseInput } from "@base-ui/react/input";
+import { Asterisk } from "lucide-react";
+import type { ComponentProps } from "react";
+
+import cn from "~/utils/cn";
+
+export interface InputProps extends Omit, "onChange"> {
+ label: string;
+ labelHidden?: boolean;
+ required?: boolean;
+ className?: string;
+ invalid?: boolean;
+ errorMessage?: string;
+ description?: string;
+ onChange?: (value: string) => void;
+}
+
+export default function Input(props: InputProps) {
+ const {
+ label,
+ labelHidden,
+ className,
+ invalid,
+ errorMessage,
+ description,
+ required,
+ onChange,
+ ...rest
+ } = props;
+
+ return (
+
+
+ {label}
+ {required && }
+
+ ) => onChange(e.target.value)
+ : undefined
+ }
+ />
+ {description && (
+
+ {description}
+
+ )}
+ {invalid && errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ );
+}
diff --git a/app/components/notice.tsx b/app/components/notice.tsx
new file mode 100644
index 0000000..07abe6c
--- /dev/null
+++ b/app/components/notice.tsx
@@ -0,0 +1,34 @@
+import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
+import React from "react";
+
+import Card from "~/components/card";
+
+export interface NoticeProps {
+ children: React.ReactNode;
+ title?: string;
+ variant?: "default" | "error" | "warning";
+ icon?: React.ReactElement;
+}
+
+export default function Notice({ children, title, variant, icon }: NoticeProps) {
+ return (
+
+
+ {title ? {title} : undefined}
+ {!variant && icon ? icon : iconForVariant(variant)}
+
+ {children}
+
+ );
+}
+
+function iconForVariant(variant?: "default" | "error" | "warning") {
+ switch (variant) {
+ case "error":
+ return ;
+ case "warning":
+ return ;
+ default:
+ return ;
+ }
+}
diff --git a/app/components/number-input.tsx b/app/components/number-input.tsx
new file mode 100644
index 0000000..194ae3e
--- /dev/null
+++ b/app/components/number-input.tsx
@@ -0,0 +1,68 @@
+import { NumberField } from "@base-ui/react/number-field";
+import { Minus, Plus } from "lucide-react";
+
+import cn from "~/utils/cn";
+
+export interface NumberInputProps {
+ label?: string;
+ name?: string;
+ description?: string;
+ required?: boolean;
+ disabled?: boolean;
+ min?: number;
+ max?: number;
+ step?: number;
+ defaultValue?: number;
+ value?: number;
+ onValueChange?: (value: number | null) => void;
+}
+
+export default function NumberInput(props: NumberInputProps) {
+ const { label, name, description } = props;
+
+ return (
+
+ {label && (
+
+
+
+ {label}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {description && (
+ {description}
+ )}
+
+ );
+}
diff --git a/app/components/radio-group.tsx b/app/components/radio-group.tsx
new file mode 100644
index 0000000..03a2fb8
--- /dev/null
+++ b/app/components/radio-group.tsx
@@ -0,0 +1,60 @@
+import { Radio } from "@base-ui/react/radio";
+import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
+import type React from "react";
+
+import cn from "~/utils/cn";
+
+interface RadioGroupProps {
+ children: React.ReactNode;
+ label: string;
+ className?: string;
+ name?: string;
+ value?: string;
+ defaultValue?: string;
+ onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
+}
+
+function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+interface RadioItemProps {
+ value: string;
+ label: string;
+ className?: string;
+ disabled?: boolean;
+ children?: React.ReactNode;
+}
+
+function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
+ return (
+
+
+
+
+ {children}
+
+ );
+}
+
+export default Object.assign(RadioGroup, { Radio: RadioItem });
diff --git a/app/components/select.tsx b/app/components/select.tsx
new file mode 100644
index 0000000..7e482b6
--- /dev/null
+++ b/app/components/select.tsx
@@ -0,0 +1,138 @@
+import { Combobox } from "@base-ui/react/combobox";
+import { Check, ChevronDown } from "lucide-react";
+
+import cn from "~/utils/cn";
+
+export interface SelectItem {
+ value: string;
+ label: string;
+}
+
+export interface SelectProps {
+ items: SelectItem[];
+ label?: string;
+ "aria-label"?: string;
+ name?: string;
+ className?: string;
+ placeholder?: string;
+ description?: string;
+ required?: boolean;
+ disabled?: boolean;
+ invalid?: boolean;
+ value?: string | null;
+ defaultValue?: string | null;
+ onValueChange?: (value: string | null) => void;
+}
+
+export default function Select({
+ items,
+ label,
+ className,
+ placeholder,
+ description,
+ required,
+ disabled,
+ invalid,
+ value,
+ defaultValue,
+ onValueChange,
+ name,
+ ...props
+}: SelectProps) {
+ const selectedItem =
+ value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
+ const defaultSelectedItem =
+ defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
+
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+
onValueChange?.(item?.value ?? null)}
+ disabled={disabled}
+ name={name}
+ aria-label={props["aria-label"]}
+ >
+
+
+
+
+
+
+
+
+
+
+ No results found.
+
+
+ {(item: SelectItem) => (
+
+ {item.label}
+
+
+
+
+ )}
+
+
+
+
+
+ {description && (
+
{description}
+ )}
+
+ );
+}
diff --git a/app/components/StatusCircle.tsx b/app/components/status-circle.tsx
similarity index 100%
rename from app/components/StatusCircle.tsx
rename to app/components/status-circle.tsx
diff --git a/app/components/switch.tsx b/app/components/switch.tsx
new file mode 100644
index 0000000..66cafd2
--- /dev/null
+++ b/app/components/switch.tsx
@@ -0,0 +1,45 @@
+import { Switch as BaseSwitch } from "@base-ui/react/switch";
+
+import cn from "~/utils/cn";
+
+export interface SwitchProps {
+ label: string;
+ className?: string;
+ switchClassName?: string;
+ name?: string;
+ disabled?: boolean;
+ checked?: boolean;
+ defaultChecked?: boolean;
+ onCheckedChange?: (checked: boolean) => void;
+}
+
+export default function Switch(props: SwitchProps) {
+ return (
+
+
+
+ );
+}
diff --git a/app/components/TableList.tsx b/app/components/table-list.tsx
similarity index 100%
rename from app/components/TableList.tsx
rename to app/components/table-list.tsx
diff --git a/app/components/Tabs.tsx b/app/components/tabs.tsx
similarity index 100%
rename from app/components/Tabs.tsx
rename to app/components/tabs.tsx
diff --git a/app/components/tags/ExitNode.tsx b/app/components/tags/ExitNode.tsx
index 0456c43..56249fe 100644
--- a/app/components/tags/ExitNode.tsx
+++ b/app/components/tags/ExitNode.tsx
@@ -1,32 +1,32 @@
-import { Info } from 'lucide-react';
-import cn from '~/utils/cn';
-import Chip from '../Chip';
-import Tooltip from '../Tooltip';
+import { Info } from "lucide-react";
+
+import cn from "~/utils/cn";
+
+import Chip from "../chip";
+import Tooltip from "../tooltip";
export interface ExitNodeTagProps {
- isEnabled?: boolean;
+ isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
- return (
-
- }
- />
-
- {isEnabled ? (
- <>This machine is acting as an exit node.>
- ) : (
- <>
- This machine is requesting to be used as an exit node. Review this
- from the "Edit route settings..." option in the machine's menu.
- >
- )}
-
-
- );
+ return (
+
+ }
+ />
+
+ {isEnabled ? (
+ <>This machine is acting as an exit node.>
+ ) : (
+ <>
+ This machine is requesting to be used as an exit node. Review this from the "Edit route
+ settings..." option in the machine's menu.
+ >
+ )}
+
+
+ );
}
diff --git a/app/components/tags/Expiry.tsx b/app/components/tags/Expiry.tsx
index 0bd845d..15fd9eb 100644
--- a/app/components/tags/Expiry.tsx
+++ b/app/components/tags/Expiry.tsx
@@ -1,5 +1,5 @@
-import Chip from "../Chip";
-import Tooltip from "../Tooltip";
+import Chip from "../chip";
+import Tooltip from "../tooltip";
export interface ExpiryTagProps {
variant: "expired" | "no-expiry";
diff --git a/app/components/tags/HeadplaneAgent.tsx b/app/components/tags/HeadplaneAgent.tsx
index 575788c..177a9ca 100644
--- a/app/components/tags/HeadplaneAgent.tsx
+++ b/app/components/tags/HeadplaneAgent.tsx
@@ -1,20 +1,19 @@
-import cn from '~/utils/cn';
-import Chip from '../Chip';
-import Tooltip from '../Tooltip';
+import cn from "~/utils/cn";
+
+import Chip from "../chip";
+import Tooltip from "../tooltip";
export function HeadplaneAgentTag() {
- return (
-
-
-
- This machine is running the Headplane agent, which allows it to provide
- host information in the web UI.
-
-
- );
+ return (
+
+
+
+ This machine is running the Headplane agent, which allows it to provide host information in
+ the web UI.
+
+
+ );
}
diff --git a/app/components/tags/Subnet.tsx b/app/components/tags/Subnet.tsx
index 7bfb46d..ae7758d 100644
--- a/app/components/tags/Subnet.tsx
+++ b/app/components/tags/Subnet.tsx
@@ -1,32 +1,32 @@
-import { Info } from 'lucide-react';
-import cn from '~/utils/cn';
-import Chip from '../Chip';
-import Tooltip from '../Tooltip';
+import { Info } from "lucide-react";
+
+import cn from "~/utils/cn";
+
+import Chip from "../chip";
+import Tooltip from "../tooltip";
export interface SubnetTagProps {
- isEnabled?: boolean;
+ isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
- return (
-
- }
- />
-
- {isEnabled ? (
- <>This machine advertises subnet routes.>
- ) : (
- <>
- This machine has unadvertised subnet routes. Review this from the
- "Edit route settings..." option in the machine's menu.
- >
- )}
-
-
- );
+ return (
+
+ }
+ />
+
+ {isEnabled ? (
+ <>This machine advertises subnet routes.>
+ ) : (
+ <>
+ This machine has unadvertised subnet routes. Review this from the "Edit route
+ settings..." option in the machine's menu.
+ >
+ )}
+
+
+ );
}
diff --git a/app/components/tags/TailscaleSSH.tsx b/app/components/tags/TailscaleSSH.tsx
index 96262b9..63d70e1 100644
--- a/app/components/tags/TailscaleSSH.tsx
+++ b/app/components/tags/TailscaleSSH.tsx
@@ -1,21 +1,19 @@
-import cn from '~/utils/cn';
-import Chip from '../Chip';
-import Tooltip from '../Tooltip';
+import cn from "~/utils/cn";
+
+import Chip from "../chip";
+import Tooltip from "../tooltip";
export function TailscaleSSHTag() {
- return (
-
-
-
- This machine advertises Tailscale SSH, which allows you to authenticate
- SSH credentials using your Tailscale account and via the Headplane web
- UI.
-
-
- );
+ return (
+
+
+
+ This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials
+ using your Tailscale account and via the Headplane web UI.
+
+
+ );
}
diff --git a/app/components/text.tsx b/app/components/text.tsx
new file mode 100644
index 0000000..ee97113
--- /dev/null
+++ b/app/components/text.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+
+import cn from "~/utils/cn";
+
+export interface TextProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export default function Text({ children, className }: TextProps) {
+ return {children}
;
+}
diff --git a/app/components/title.tsx b/app/components/title.tsx
new file mode 100644
index 0000000..c14f35b
--- /dev/null
+++ b/app/components/title.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+
+import cn from "~/utils/cn";
+
+export interface TitleProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export default function Title({ children, className }: TitleProps) {
+ return {children} ;
+}
diff --git a/app/components/Tooltip.tsx b/app/components/tooltip.tsx
similarity index 100%
rename from app/components/Tooltip.tsx
rename to app/components/tooltip.tsx
diff --git a/app/layout/link-account.tsx b/app/layout/link-account.tsx
index a1570ec..69c5717 100644
--- a/app/layout/link-account.tsx
+++ b/app/layout/link-account.tsx
@@ -1,7 +1,7 @@
import { Form } from "react-router";
import Button from "~/components/button";
-import Card from "~/components/Card";
+import Card from "~/components/card";
import cn from "~/utils/cn";
interface LinkAccountProps {
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index 34dfc63..a647b26 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -3,12 +3,12 @@ import { useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
-import Card from "~/components/Card";
-import Code from "~/components/Code";
+import Card from "~/components/card";
+import Code from "~/components/code";
import Link from "~/components/link";
-import Notice from "~/components/Notice";
+import Notice from "~/components/notice";
import PageError from "~/components/page-error";
-import Tabs from "~/components/Tabs";
+import Tabs from "~/components/tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx
index 09f5ce8..3aaa09b 100644
--- a/app/routes/auth/login/config-error.tsx
+++ b/app/routes/auth/login/config-error.tsx
@@ -1,7 +1,7 @@
import { AlertCircle, CloudOff } from "lucide-react";
-import Card from "~/components/Card";
-import Code from "~/components/Code";
+import Card from "~/components/card";
+import Code from "~/components/code";
import Link from "~/components/link";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
diff --git a/app/routes/auth/login/logout.tsx b/app/routes/auth/login/logout.tsx
index bdde65f..d972140 100644
--- a/app/routes/auth/login/logout.tsx
+++ b/app/routes/auth/login/logout.tsx
@@ -1,15 +1,14 @@
-import Card from '~/components/Card';
+import Card from "~/components/card";
export default function Logout() {
- return (
-
-
- You have been logged out
-
- You can now close this window. If you would like to log in again,
- please refresh the page.
-
-
-
- );
+ return (
+
+
+ You have been logged out
+
+ You can now close this window. If you would like to log in again, please refresh the page.
+
+
+
+ );
}
diff --git a/app/routes/auth/login/oidc-error.tsx b/app/routes/auth/login/oidc-error.tsx
index c4296d2..4aba530 100644
--- a/app/routes/auth/login/oidc-error.tsx
+++ b/app/routes/auth/login/oidc-error.tsx
@@ -1,63 +1,60 @@
-import { AlertCircle } from 'lucide-react';
-import Card from '~/components/Card';
-import Code from '~/components/Code';
+import { AlertCircle } from "lucide-react";
+
+import Card from "~/components/card";
+import Code from "~/components/code";
export function OidcErrorNotice({ code }: { code: string }) {
- return (
-
-
-
Configuration Issue(s)
-
-
- {getErrorMessage(code)}
-
- );
+ return (
+
+
+
Configuration Issue(s)
+
+
+ {getErrorMessage(code)}
+
+ );
}
function getErrorMessage(code: string) {
- switch (code) {
- case 'error_no_query':
- return (
-
- The SSO provider did not correctly redirect back to Headplane with the
- required parameters. Please ensure your SSO provider is configured
- correctly.
-
- );
+ switch (code) {
+ case "error_no_query":
+ return (
+
+ The SSO provider did not correctly redirect back to Headplane with the required
+ parameters. Please ensure your SSO provider is configured correctly.
+
+ );
- case 'error_no_session':
- case 'error_invalid_session':
- return (
-
- Unable to complete SSO login due to missing or invalid session data.
- Ensure that your Headplane cookie configuration is correct and that
- your browser is accepting cookies.
-
- );
+ case "error_no_session":
+ case "error_invalid_session":
+ return (
+
+ Unable to complete SSO login due to missing or invalid session data. Ensure that your
+ Headplane cookie configuration is correct and that your browser is accepting cookies.
+
+ );
- case 'error_no_sub':
- return (
-
- The SSO provider did not return a valid user identifier. Please ensure
- your SSO provider is correctly configured to provide the{' '}
- sub claim.
-
- );
+ case "error_no_sub":
+ return (
+
+ The SSO provider did not return a valid user identifier. Please ensure your SSO provider
+ is correctly configured to provide the sub claim.
+
+ );
- case 'error_auth_failed':
- return (
-
- Authentication with the SSO provider failed. Please try again later.
- Headplane logs may provide more information.
-
- );
+ case "error_auth_failed":
+ return (
+
+ Authentication with the SSO provider failed. Please try again later. Headplane logs may
+ provide more information.
+
+ );
- default:
- return (
-
- An unknown error occurred during OIDC authentication. Please try again
- later.
-
- );
- }
+ default:
+ return (
+
+ An unknown error occurred during OIDC authentication. Please try again later.
+
+ );
+ }
}
diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx
index 2135f00..349866a 100644
--- a/app/routes/auth/login/page.tsx
+++ b/app/routes/auth/login/page.tsx
@@ -3,9 +3,9 @@ import { useEffect, useState } from "react";
import { Form, redirect, useSearchParams } from "react-router";
import Button from "~/components/button";
-import Card from "~/components/Card";
-import Code from "~/components/Code";
-import Input from "~/components/Input";
+import Card from "~/components/card";
+import Code from "~/components/code";
+import Input from "~/components/input";
import Link from "~/components/link";
import { useLiveData } from "~/utils/live-data";
@@ -122,7 +122,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Override DNS servers
{
+ onCheckedChange={(v) => {
submit(
{
action_id: "override_dns",
diff --git a/app/routes/dns/components/manage-records.tsx b/app/routes/dns/components/manage-records.tsx
index 3ac6056..3e84c06 100644
--- a/app/routes/dns/components/manage-records.tsx
+++ b/app/routes/dns/components/manage-records.tsx
@@ -1,9 +1,9 @@
import { Form } from "react-router";
import Button from "~/components/button";
-import Code from "~/components/Code";
+import Code from "~/components/code";
import Link from "~/components/link";
-import TableList from "~/components/TableList";
+import TableList from "~/components/table-list";
import cn from "~/utils/cn";
import AddRecord from "../dialogs/add-record";
diff --git a/app/routes/dns/components/rename-tailnet.tsx b/app/routes/dns/components/rename-tailnet.tsx
index 5e659d1..88e34ac 100644
--- a/app/routes/dns/components/rename-tailnet.tsx
+++ b/app/routes/dns/components/rename-tailnet.tsx
@@ -1,9 +1,9 @@
import Button from "~/components/button";
-import Code from "~/components/Code";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Code from "~/components/code";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
interface Props {
name: string;
@@ -20,11 +20,11 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
{
- event.target.select();
+ (event.target as HTMLInputElement).select();
}}
value={name}
/>
@@ -39,7 +39,7 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
This nameserver will only be used for some domains.
-
+
{split ? (
<>
Domain
{
- if (v) setType(v.toString() as "A" | "AAAA");
+ defaultValue={type}
+ onValueChange={(v) => {
+ if (v) setType(v as "A" | "AAAA");
}}
- >
- A
- AAAA
-
+ items={[
+ { value: "A", label: "A" },
+ { value: "AAAA", label: "AAAA" },
+ ]}
+ />
{isDuplicate ? (
diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx
index 8d4b361..dd246be 100644
--- a/app/routes/dns/overview.tsx
+++ b/app/routes/dns/overview.tsx
@@ -1,8 +1,8 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
-import Code from "~/components/Code";
-import Notice from "~/components/Notice";
+import Code from "~/components/code";
+import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import type { LoadContext } from "~/server";
import { Capabilities } from "~/server/web/roles";
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 178eb5f..0cdb589 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -7,7 +7,7 @@ import linuxSvg from "~/assets/linux.svg";
import macosSvg from "~/assets/macos.svg";
import windowsSvg from "~/assets/windows.svg";
import Button from "~/components/button";
-import Card from "~/components/Card";
+import Card from "~/components/card";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
import { usersResource } from "~/server/headscale/live-store";
diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx
index e1ecb58..d5812b2 100644
--- a/app/routes/machines/components/machine-row.tsx
+++ b/app/routes/machines/components/machine-row.tsx
@@ -1,10 +1,10 @@
import { ChevronDown, Copy } from "lucide-react";
import { useMemo } from "react";
-import Chip from "~/components/Chip";
+import Chip from "~/components/chip";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
-import StatusCircle from "~/components/StatusCircle";
+import StatusCircle from "~/components/status-circle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
diff --git a/app/routes/machines/dialogs/delete.tsx b/app/routes/machines/dialogs/delete.tsx
index c746e52..ffc7b42 100644
--- a/app/routes/machines/dialogs/delete.tsx
+++ b/app/routes/machines/dialogs/delete.tsx
@@ -1,8 +1,8 @@
import { useNavigate } from "react-router";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine } from "~/types";
interface DeleteProps {
diff --git a/app/routes/machines/dialogs/expire.tsx b/app/routes/machines/dialogs/expire.tsx
index 76e70b5..7e67700 100644
--- a/app/routes/machines/dialogs/expire.tsx
+++ b/app/routes/machines/dialogs/expire.tsx
@@ -1,6 +1,6 @@
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine } from "~/types";
interface ExpireProps {
diff --git a/app/routes/machines/dialogs/move.tsx b/app/routes/machines/dialogs/move.tsx
index d5f5ab0..1d4fd51 100644
--- a/app/routes/machines/dialogs/move.tsx
+++ b/app/routes/machines/dialogs/move.tsx
@@ -1,9 +1,9 @@
-import { Key, useState } from "react";
+import { useState } from "react";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Select from "~/components/Select";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Select from "~/components/select";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine, User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
@@ -15,7 +15,7 @@ interface MoveProps {
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
- const [userId, setUserId] = useState(machine.user?.id ?? null);
+ const [userId, setUserId] = useState(machine.user?.id ?? null);
return (
@@ -26,19 +26,19 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
{
+ onValueChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
- >
- {users.map((user) => (
- {getUserDisplayName(user)}
- ))}
-
+ items={users.map((user) => ({
+ value: user.id,
+ label: getUserDisplayName(user),
+ }))}
+ />
);
diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx
index a9d4aa9..2555dd8 100644
--- a/app/routes/machines/dialogs/new.tsx
+++ b/app/routes/machines/dialogs/new.tsx
@@ -2,13 +2,13 @@ import { Computer, FileKey2 } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
-import Code from "~/components/Code";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
+import Code from "~/components/code";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
-import Select from "~/components/Select";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Select from "~/components/select";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
@@ -38,19 +38,23 @@ export default function NewMachine(data: NewMachineProps) {
-
- {data.users.map((user) => (
- {getUserDisplayName(user)}
- ))}
-
+ ({
+ value: user.id,
+ label: getUserDisplayName(user),
+ }))}
+ />
diff --git a/app/routes/machines/dialogs/rename.tsx b/app/routes/machines/dialogs/rename.tsx
index a3d1549..f5e0f4d 100644
--- a/app/routes/machines/dialogs/rename.tsx
+++ b/app/routes/machines/dialogs/rename.tsx
@@ -1,10 +1,10 @@
import { useState } from "react";
-import Code from "~/components/Code";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Code from "~/components/code";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine } from "~/types";
interface RenameProps {
@@ -29,41 +29,11 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
{
- if (value.length === 0) {
- return "Cannot be empty";
- }
-
- // DNS hostname validation
- if (value.toLowerCase() !== value) {
- return "Cannot contain uppercase letters";
- }
-
- if (value.length > 63) {
- return "DNS hostnames cannot be 64+ characters";
- }
-
- // Test for invalid characters
- if (!/^[a-z0-9-]+$/.test(value)) {
- return "Cannot contain special characters";
- }
-
- // Test for leading/trailing hyphens
- if (value.startsWith("-") || value.endsWith("-")) {
- return "Cannot start or end with a hyphen";
- }
-
- // Test for consecutive hyphens
- if (value.includes("--")) {
- return "Cannot contain consecutive hyphens";
- }
- }}
- validationBehavior="native"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
diff --git a/app/routes/machines/dialogs/routes.tsx b/app/routes/machines/dialogs/routes.tsx
index 530bc78..fa8fde4 100644
--- a/app/routes/machines/dialogs/routes.tsx
+++ b/app/routes/machines/dialogs/routes.tsx
@@ -1,12 +1,12 @@
import { GlobeLock, RouteOff } from "lucide-react";
import { useFetcher } from "react-router";
-import Dialog, { DialogPanel } from "~/components/Dialog";
+import Dialog, { DialogPanel } from "~/components/dialog";
import Link from "~/components/link";
-import Switch from "~/components/Switch";
-import TableList from "~/components/TableList";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Switch from "~/components/switch";
+import TableList from "~/components/table-list";
+import Text from "~/components/text";
+import Title from "~/components/title";
import { PopulatedNode } from "~/utils/node-info";
interface RoutesProps {
@@ -47,9 +47,9 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
{route}
{
+ onCheckedChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
@@ -81,9 +81,9 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
Use as exit node
{
+ onCheckedChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx
index 95dd0b6..6affc80 100644
--- a/app/routes/machines/dialogs/tags.tsx
+++ b/app/routes/machines/dialogs/tags.tsx
@@ -3,12 +3,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/button";
-import Dialog, { DialogPanel } from "~/components/Dialog";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
import Link from "~/components/link";
-import Select from "~/components/Select";
-import TableList from "~/components/TableList";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import TableList from "~/components/table-list";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine } from "~/types";
import cn from "~/utils/cn";
@@ -29,11 +29,6 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
[tag, tags],
);
- const validNodeTags = useMemo(
- () => existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [],
- [tags],
- );
-
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
useEffect(() => {
@@ -112,19 +107,16 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
- 0 && tagIsInvalid}
- onInputChange={setTag}
+ value={tag}
+ onChange={setTag}
+ invalid={tag.length > 0 && tagIsInvalid}
placeholder="tag:example"
- >
- {validNodeTags.map((nodeTag) => (
- {nodeTag}
- ))}
-
+ label="Tag"
+ labelHidden
+ />
(availableUsers[0]?.id);
+ const [userId, setUserId] = useState(availableUsers[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -138,9 +137,9 @@ export default function AddAuthKey({
Create a key owned by ACL tags instead of a user.
setTagOnly(!tagOnly)}
+ onCheckedChange={() => setTagOnly(!tagOnly)}
/>
)}
@@ -153,23 +152,23 @@ export default function AddAuthKey({
? "You can only create keys for your own user."
: "Machines will belong to this user when they authenticate."
}
- isDisabled={selfServiceOnly}
- isRequired
+ disabled={selfServiceOnly}
+ required
label="User"
- onSelectionChange={(value) => setUserId(value)}
+ onValueChange={(value) => setUserId(value)}
placeholder="Select a user"
- selectedKey={userId}
- >
- {availableUsers.map((user) => (
- {getUserDisplayName(user)}
- ))}
-
+ value={userId}
+ items={availableUsers.map((user) => ({
+ value: user.id,
+ label: getUserDisplayName(user),
+ }))}
+ />
)}
setTags(value)}
placeholder="server, prod"
@@ -178,15 +177,10 @@ export default function AddAuthKey({
@@ -195,9 +189,9 @@ export default function AddAuthKey({
Use this key to authenticate more than one device.
setReusable(!reusable)}
+ onCheckedChange={() => setReusable(!reusable)}
/>
@@ -212,9 +206,9 @@ export default function AddAuthKey({
setEphemeral(!ephemeral)}
+ onCheckedChange={() => setEphemeral(!ephemeral)}
/>
diff --git a/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx b/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx
index b8a2792..01945a6 100644
--- a/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx
+++ b/app/routes/settings/auth-keys/dialogs/expire-auth-key.tsx
@@ -1,7 +1,7 @@
import Button from "~/components/button";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { PreAuthKey, User } from "~/types";
interface ExpireAuthKeyProps {
diff --git a/app/routes/settings/auth-keys/overview.tsx b/app/routes/settings/auth-keys/overview.tsx
index 6dd9f85..881107d 100644
--- a/app/routes/settings/auth-keys/overview.tsx
+++ b/app/routes/settings/auth-keys/overview.tsx
@@ -1,11 +1,11 @@
import { FileKey2 } from "lucide-react";
import { useMemo, useState } from "react";
-import Code from "~/components/Code";
+import Code from "~/components/code";
import Link from "~/components/link";
-import Notice from "~/components/Notice";
-import Select from "~/components/Select";
-import TableList from "~/components/TableList";
+import Notice from "~/components/notice";
+import Select from "~/components/select";
+import TableList from "~/components/table-list";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
@@ -207,38 +207,36 @@ export default function Page({
setSelectedUser(value?.toString() ?? "")}
+ onValueChange={(value) => setSelectedUser(value ?? "")}
placeholder="Select a user"
- >
- {[
- All ,
+ items={[
+ { value: "__headplane_all", label: "All" },
...keys
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
- .map(({ user }) => (
- {getUserDisplayName(user)}
- )),
+ .map(({ user }) => ({ value: user.id, label: getUserDisplayName(user) })),
...(keys.some(({ user }) => user === null)
- ? [Tag Only ]
+ ? [{ value: "__headplane_tag_only", label: "Tag Only" }]
: []),
]}
-
+ />
setStatus((value?.toString() ?? "active") as Status)}
+ onValueChange={(value) => setStatus((value ?? "active") as Status)}
placeholder="Select a status"
- >
- All
- Active
- Used/Expired
- Reusable
- Ephemeral
-
+ items={[
+ { value: "all", label: "All" },
+ { value: "active", label: "Active" },
+ { value: "expired", label: "Used/Expired" },
+ { value: "reusable", label: "Reusable" },
+ { value: "ephemeral", label: "Ephemeral" },
+ ]}
+ />
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
diff --git a/app/routes/settings/restrictions/dialogs/add-domain.tsx b/app/routes/settings/restrictions/dialogs/add-domain.tsx
index ad39b59..64efb46 100644
--- a/app/routes/settings/restrictions/dialogs/add-domain.tsx
+++ b/app/routes/settings/restrictions/dialogs/add-domain.tsx
@@ -1,10 +1,10 @@
import { useMemo, useState } from "react";
import Button from "~/components/button";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
interface AddDomainProps {
domains: string[];
@@ -50,8 +50,8 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
? `Matches users with @${domain.trim()}`
: "Enter a domain to match users with their email addresses."
}
- isInvalid={domain.trim().length === 0 || isInvalid}
- isRequired
+ invalid={domain.trim().length === 0 || isInvalid}
+ required
label="Domain"
name="domain"
onChange={setDomain}
diff --git a/app/routes/settings/restrictions/dialogs/add-group.tsx b/app/routes/settings/restrictions/dialogs/add-group.tsx
index 5da5a6b..415cb55 100644
--- a/app/routes/settings/restrictions/dialogs/add-group.tsx
+++ b/app/routes/settings/restrictions/dialogs/add-group.tsx
@@ -1,10 +1,10 @@
import { useMemo, useState } from "react";
import Button from "~/components/button";
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
interface AddGroupProps {
groups: string[];
@@ -36,8 +36,8 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
- {
- if (value.trim().length === 0) {
- return "Username is required";
- }
-
- if (value.includes(" ")) {
- return "Usernames cannot contain spaces";
- }
-
- return true;
- }}
- validationBehavior="native"
- />
-
-
+
+
+
diff --git a/app/routes/users/dialogs/delete-user.tsx b/app/routes/users/dialogs/delete-user.tsx
index 666212f..ac8bcb9 100644
--- a/app/routes/users/dialogs/delete-user.tsx
+++ b/app/routes/users/dialogs/delete-user.tsx
@@ -1,6 +1,6 @@
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Text from "~/components/text";
+import Title from "~/components/title";
import type { Machine, User } from "~/types";
interface DeleteProps {
diff --git a/app/routes/users/dialogs/link-user.tsx b/app/routes/users/dialogs/link-user.tsx
index cfc3b1e..ac1510b 100644
--- a/app/routes/users/dialogs/link-user.tsx
+++ b/app/routes/users/dialogs/link-user.tsx
@@ -1,7 +1,7 @@
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Notice from "~/components/Notice";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Notice from "~/components/notice";
+import Text from "~/components/text";
+import Title from "~/components/title";
import cn from "~/utils/cn";
interface LinkUserProps {
diff --git a/app/routes/users/dialogs/reassign-user.tsx b/app/routes/users/dialogs/reassign-user.tsx
index a504e08..6bc40e6 100644
--- a/app/routes/users/dialogs/reassign-user.tsx
+++ b/app/routes/users/dialogs/reassign-user.tsx
@@ -1,9 +1,9 @@
-import Dialog, { DialogPanel } from "~/components/Dialog";
+import Dialog, { DialogPanel } from "~/components/dialog";
import Link from "~/components/link";
-import Notice from "~/components/Notice";
-import RadioGroup from "~/components/RadioGroup";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Notice from "~/components/notice";
+import RadioGroup from "~/components/radio-group";
+import Text from "~/components/text";
+import Title from "~/components/title";
import { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -39,13 +39,7 @@ export default function ReassignUser({
<>
-
+
{Object.keys(Roles)
.filter((r) => r !== "owner")
.map((r) => {
diff --git a/app/routes/users/dialogs/rename-user.tsx b/app/routes/users/dialogs/rename-user.tsx
index f376ea9..2d67910 100644
--- a/app/routes/users/dialogs/rename-user.tsx
+++ b/app/routes/users/dialogs/rename-user.tsx
@@ -1,7 +1,7 @@
-import Dialog, { DialogPanel } from "~/components/Dialog";
-import Input from "~/components/Input";
-import Text from "~/components/Text";
-import Title from "~/components/Title";
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
import { User } from "~/types";
interface RenameProps {
@@ -24,7 +24,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {