feat: swap button and decompose dialog components

This commit is contained in:
Aarnav Tale
2026-03-14 19:22:18 -04:00
parent eda5713700
commit 27f8fa0b42
40 changed files with 604 additions and 872 deletions
+4 -15
View File
@@ -14,19 +14,13 @@ import {
useOverlayTriggerState,
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Text from "~/components/Text";
import Title from "~/components/Title";
import Button, { ButtonProps } from "~/components/button";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
export interface DialogProps extends OverlayTriggerProps {
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
]
| [React.ReactElement<ButtonProps>, React.ReactElement<DialogPanelProps>]
| React.ReactElement<DialogPanelProps>;
}
@@ -179,10 +173,5 @@ function DModal(props: DModalProps) {
);
}
export default Object.assign(Dialog, {
Button,
IconButton,
Panel,
Title,
Text,
});
export { Panel as DialogPanel };
export default Dialog;
-43
View File
@@ -1,43 +0,0 @@
import React, { useRef } from "react";
import { type AriaButtonOptions, useButton } from "react-aria";
import cn from "~/utils/cn";
export interface IconButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light";
className?: string;
children: React.ReactNode;
label: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
export default function IconButton({ variant = "light", ...props }: IconButtonProps) {
// In case the button is used as a trigger ref
const ref = props.ref ?? useRef<HTMLButtonElement | null>(null);
const { buttonProps } = useButton(props, ref);
return (
<button
ref={ref}
{...buttonProps}
aria-label={props.label}
className={cn(
"flex items-center justify-center rounded-full p-1",
"transition-colors duration-100",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
props.isDisabled && "pointer-events-none opacity-50",
...(variant === "heavy"
? [
"bg-indigo-500 font-semibold text-white",
"hover:bg-indigo-500/90",
"dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80",
]
: ["bg-mist-100 dark:bg-mist-700/30", "hover:bg-mist-200/90 dark:hover:bg-mist-800/30"]),
props.className,
)}
>
{props.children}
</button>
);
}
+15 -7
View File
@@ -3,7 +3,7 @@ import { useRef } from "react";
import { type AriaNumberFieldProps, useId, useLocale, useNumberField } from "react-aria";
import { useNumberFieldState } from "react-stately";
import IconButton from "~/components/IconButton";
import Button from "~/components/button";
import cn from "~/utils/cn";
export interface InputProps extends AriaNumberFieldProps {
@@ -57,12 +57,20 @@ export default function NumberInput(props: InputProps) {
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton {...decrementButtonProps} label="Decrement" className="h-7.5 w-7.5 rounded-lg">
<Minus className="p-1" />
</IconButton>
<IconButton {...incrementButtonProps} label="Increment" className="h-7.5 w-7.5 rounded-lg">
<Plus className="p-1" />
</IconButton>
<Button
{...decrementButtonProps}
aria-label="Decrement"
className="h-7.5 w-7.5 rounded-lg p-1"
>
<Minus className="h-4 w-4" />
</Button>
<Button
{...incrementButtonProps}
aria-label="Increment"
className="h-7.5 w-7.5 rounded-lg p-1"
>
<Plus className="h-4 w-4" />
</Button>
</div>
{props.description && (
<div
-70
View File
@@ -1,70 +0,0 @@
import { useRef } from "react";
import { AriaTabListProps, AriaTabPanelProps, useTab, useTabList, useTabPanel } from "react-aria";
import { Item, Node, TabListState, useTabListState } from "react-stately";
import cn from "~/utils/cn";
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn("flex flex-col", className)}>
<div {...tabListProps} ref={ref} className="flex items-center gap-2 overflow-x-scroll">
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
"pl-0.5 pr-2 py-0.5 rounded-md cursor-pointer",
"aria-selected:bg-mist-100 dark:aria-selected:bg-mist-950",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"border border-mist-100 dark:border-mist-800",
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="mt-2 w-full">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });
-21
View File
@@ -1,21 +0,0 @@
import clsx from 'clsx';
interface Props {
className?: string;
}
export default function Spinner({ className }: Props) {
return (
<div className={clsx('inline-block align-middle mb-0.5', className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
className,
)}
>
<span className="sr-only">Loading...</span>
</div>
</div>
);
}
+6 -5
View File
@@ -3,7 +3,7 @@ import { ToastQueue, ToastState, useToastQueue } from "@react-stately/toast";
import { X } from "lucide-react";
import React, { useRef } from "react";
import IconButton from "~/components/IconButton";
import Button from "~/components/button";
import cn from "~/utils/cn";
interface ToastProps extends AriaToastProps<React.ReactNode> {
@@ -27,16 +27,17 @@ function Toast({ state, ...props }: ToastProps) {
<div {...contentProps} className="flex flex-col gap-2">
<div {...titleProps}>{props.toast.content}</div>
</div>
<IconButton
<Button
{...closeButtonProps}
label="Close"
aria-label="Close"
className={cn(
"rounded-full p-1",
"bg-transparent hover:bg-mist-700",
"dark:bg-transparent dark:hover:bg-mist-800",
)}
>
<X className="p-1" />
</IconButton>
<X className="h-4 w-4" />
</Button>
</div>
);
}
@@ -7,6 +7,7 @@ export interface ButtonProps extends AriaButtonOptions<"button"> {
variant?: "heavy" | "light" | "danger" | "ghost";
className?: string;
children?: React.ReactNode;
"aria-label"?: string;
ref?: React.RefObject<HTMLButtonElement | null>;
}
@@ -19,8 +20,9 @@ export default function Button({ variant = "light", ...props }: ButtonProps) {
<button
ref={ref}
{...buttonProps}
aria-label={props["aria-label"]}
className={cn(
"w-fit rounded-md px-3.5 py-2 text-sm leading-tight",
"inline-flex w-fit items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm",
"transition-colors duration-100",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
-164
View File
@@ -1,164 +0,0 @@
import React, { useRef, cloneElement } from "react";
import { type AriaMenuProps, Key, Placement, useMenuTrigger } from "react-aria";
import { useMenu, useMenuItem, useMenuSection, useSeparator } from "react-aria";
import { Item, Section } from "react-stately";
import {
type MenuTriggerProps,
Node,
TreeState,
useMenuTriggerState,
useTreeState,
} from "react-stately";
import Button, { ButtonProps } from "~/components/Button";
import IconButton, { IconButtonProps } from "~/components/IconButton";
import Popover from "~/components/Popover";
import cn from "~/utils/cn";
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
];
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = "bottom", isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>({}, state, ref);
// cloneElement is necessary because the button is a union type
// of multiple things and we need to join props from our hooks
const [button, panel] = props.children;
return (
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
<Popover state={state} triggerRef={ref} placement={placement}>
{cloneElement(panel, {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
</div>
);
}
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
const state = useTreeState(props);
const ref = useRef(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul
{...menuProps}
ref={ref}
className="min-w-[200px] rounded-md pt-1 pb-1 shadow-2xs focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
}
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
"aria-label": section["aria-label"],
});
const { separatorProps } = useSeparator({
elementType: "li",
});
return (
<>
{section.key !== state.collection.getFirstKey() ? (
<li
{...separatorProps}
className={cn("mx-2 mt-1 mb-1 border-t", "border-mist-200 dark:border-mist-800")}
/>
) : undefined}
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
</>
);
}
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
return (
<li
{...menuItemProps}
ref={ref}
className={cn(
"py-2 px-3 mx-1 rounded-lg",
"focus:outline-hidden select-none",
isFocused && "bg-mist-100/50 dark:bg-mist-800",
isDisabled
? "text-mist-400 dark:text-mist-600"
: "hover:bg-mist-100/50 dark:hover:bg-mist-800 cursor-pointer",
)}
>
{item.rendered}
</li>
);
}
export default Object.assign(Menu, {
Button,
IconButton,
Panel,
Section,
Item,
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Card from "~/components/Card";
import cn from "~/utils/cn";
+1 -1
View File
@@ -2,7 +2,7 @@ import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-rea
import { useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
+1 -1
View File
@@ -2,7 +2,7 @@ import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
+1 -1
View File
@@ -11,7 +11,7 @@ import { GripVertical, Lock } from "lucide-react";
import { useEffect, useState } from "react";
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Input from "~/components/Input";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Info } from "lucide-react";
import { Form, useSubmit } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
+1 -1
View File
@@ -1,6 +1,6 @@
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Code from "~/components/Code";
import Link from "~/components/link";
import TableList from "~/components/TableList";
+45 -42
View File
@@ -1,48 +1,51 @@
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
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";
interface Props {
name: string;
isDisabled: boolean;
name: string;
isDisabled: boolean;
}
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name"
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
name="new_name"
placeholder="ts.net"
/>
</Dialog.Panel>
</Dialog>
</div>
);
return (
<div className="flex w-full flex-col gap-y-4 sm:w-2/3">
<h1 className="mb-2 text-2xl font-medium">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{" "}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
className="w-3/5 text-sm font-medium"
isReadOnly
label="Tailnet name"
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Button isDisabled={isDisabled}>Rename Tailnet</Button>
<DialogPanel isDisabled={isDisabled}>
<Title>Rename Tailnet</Title>
<Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected behavior and may
break existing devices in your tailnet.
</Text>
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
name="new_name"
placeholder="ts.net"
/>
</DialogPanel>
</Dialog>
</div>
);
}
+20 -25
View File
@@ -1,31 +1,26 @@
import Dialog from '~/components/Dialog';
import Button from "~/components/Button";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface Props {
isEnabled: boolean;
isDisabled: boolean;
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button isDisabled={isDisabled}>{isEnabled ? "Disable" : "Enable"} Magic DNS</Button>
<DialogPanel isDisabled={isDisabled}>
<Title>{isEnabled ? "Disable" : "Enable"} Magic DNS</Title>
<Text>
Devices will no longer be accessible via your tailnet domain. The search domain will also
be disabled.
</Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input type="hidden" name="new_state" value={isEnabled ? "disabled" : "enabled"} />
</DialogPanel>
</Dialog>
);
}
+83 -84
View File
@@ -1,94 +1,93 @@
import { Split } from 'lucide-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Switch from '~/components/Switch';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import { Split } from "lucide-react";
import { useMemo, useState } from "react";
import Button from "~/components/Button";
import Chip from "~/components/Chip";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Title from "~/components/Title";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
interface Props {
nameservers: Record<string, string[]>;
nameservers: Record<string, string[]>;
}
export default function AddNameserver({ nameservers }: Props) {
const [split, setSplit] = useState(false);
const [ns, setNs] = useState('');
const [domain, setDomain] = useState('');
const [split, setSplit] = useState(false);
const [ns, setNs] = useState("");
const [domain, setDomain] = useState("");
const isInvalid = useMemo(() => {
if (ns === '') return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
const isInvalid = useMemo(() => {
if (ns === "") return false;
// Test if it's a valid IPv4 or IPv6 address
const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/;
if (!ipv4.test(ns) && !ipv6.test(ns)) return true;
if (split) {
return nameservers[domain]?.includes(ns);
}
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input name="action_id" type="hidden" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Chip
className={cn('inline-flex items-center')}
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input
isRequired={split === true}
label="Domain"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : (
<input name="split_name" type="hidden" value="global" />
)}
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button>Add nameserver</Button>
<DialogPanel>
<Title className="mb-4">Add nameserver</Title>
<input name="action_id" type="hidden" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
/>
<div className="mt-8 flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Text className="font-semibold">Restrict to domain</Text>
<Tooltip>
<Chip
className={cn("inline-flex items-center")}
leftIcon={<Split className="mr-0.5 h-3 w-3" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later for most platforms)
will use this nameserver. Older clients will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
</div>
<Switch label="Split DNS" onChange={setSplit} />
</div>
{split ? (
<>
<Text className="mt-8 font-semibold">Domain</Text>
<Input
isRequired={split === true}
label="Domain"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix should use the
nameserver.
</Text>
</>
) : (
<input name="split_name" type="hidden" value="global" />
)}
</DialogPanel>
</Dialog>
);
}
+70 -70
View File
@@ -1,79 +1,79 @@
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Select from '~/components/Select';
import { useMemo, useState } from "react";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Select from "~/components/Select";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface Props {
records: { name: string; type: 'A' | 'AAAA' | string; value: string }[];
records: { name: string; type: "A" | "AAAA" | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [type, setType] = useState<'A' | 'AAAA' | string>('A');
const [name, setName] = useState('');
const [ip, setIp] = useState('');
const [type, setType] = useState<"A" | "AAAA" | string>("A");
const [name, setName] = useState("");
const [ip, setIp] = useState("");
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false;
const lookup = records.find((record) => record.name === name);
if (!lookup) return false;
return lookup.value === ip;
}, [records, name, ip]);
return lookup.value === ip;
}, [records, name, ip]);
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel
onSubmit={() => {
setName('');
setIp('');
}}
>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as 'A' | 'AAAA');
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder={
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
}
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
</div>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button>Add DNS record</Button>
<DialogPanel
onSubmit={() => {
setName("");
setIp("");
}}
>
<Title>Add DNS record</Title>
<Text>Enter the domain and IP address for the new DNS record.</Text>
<div className="mt-4 flex flex-col gap-2">
<input type="hidden" name="action_id" value="add_record" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as "A" | "AAAA");
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<Input
isRequired
label="Domain"
placeholder="test.example.com"
name="record_name"
onChange={setName}
isInvalid={isDuplicate}
/>
<Input
isRequired
label="IP Address"
placeholder={type === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP address <Code>{ip}</Code>{" "}
already exists.
</p>
) : undefined}
</div>
</DialogPanel>
</Dialog>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import iosSvg from "~/assets/ios.svg";
import linuxSvg from "~/assets/linux.svg";
import macosSvg from "~/assets/macos.svg";
import windowsSvg from "~/assets/windows.svg";
import Button from "~/components/Button";
import Button from "~/components/button";
import Card from "~/components/Card";
import Link from "~/components/link";
import LinkAccount from "~/layout/link-account";
+1 -1
View File
@@ -1,7 +1,7 @@
import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
import { useState } from "react";
import Button from "~/components/Button";
import Button from "~/components/button";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { User } from "~/types";
import cn from "~/utils/cn";
+23 -23
View File
@@ -1,30 +1,30 @@
import { useNavigate } from 'react-router';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { useNavigate } from "react-router";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { Machine } from "~/types";
interface DeleteProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
const navigate = useNavigate();
const navigate = useNavigate();
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
onSubmit={() => navigate('/machines')}
variant="destructive"
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input name="action_id" type="hidden" value="delete" />
<input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel onSubmit={() => navigate("/machines")} variant="destructive">
<Title>Remove {machine.givenName}</Title>
<Text>
This machine will be permanently removed from your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Text>
<input name="action_id" type="hidden" value="delete" />
<input name="node_id" type="hidden" value={machine.id} />
</DialogPanel>
</Dialog>
);
}
+20 -18
View File
@@ -1,24 +1,26 @@
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { Machine } from "~/types";
interface ExpireProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input name="action_id" type="hidden" value="expire" />
<input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel variant="destructive">
<Title>Expire {machine.givenName}</Title>
<Text>
This will disconnect the machine from your Tailnet. In order to reconnect, you will need
to re-authenticate from the device.
</Text>
<input name="action_id" type="hidden" value="expire" />
<input name="node_id" type="hidden" value={machine.id} />
</DialogPanel>
</Dialog>
);
}
+7 -5
View File
@@ -1,7 +1,9 @@
import { Key, useState } from "react";
import Dialog from "~/components/Dialog";
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";
@@ -17,9 +19,9 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user?.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
<DialogPanel isDisabled={userId === machine.user?.id}>
<Title>Change the owner of {machine.givenName}</Title>
<Text>The owner of the machine is the user associated with it.</Text>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
@@ -37,7 +39,7 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+8 -6
View File
@@ -3,10 +3,12 @@ import { useState } from "react";
import { useNavigate } from "react-router";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
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 type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
@@ -27,12 +29,12 @@ export default function NewMachine(data: NewMachineProps) {
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
<DialogPanel isDisabled={mkey.length !== 24}>
<Title>Register Machine Key</Title>
<Text className="mb-4">
The machine key is given when you run{" "}
<Code isCopyable>tailscale up --login-server={data.server}</Code> on your device.
</Dialog.Text>
</Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
@@ -49,7 +51,7 @@ export default function NewMachine(data: NewMachineProps) {
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
</Dialog.Panel>
</DialogPanel>
</Dialog>
<Menu disabled={data.isDisabled}>
<MenuTrigger className="rounded-md bg-indigo-500 px-3.5 py-2 text-sm font-semibold text-white hover:bg-indigo-500/90 dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80">
+8 -6
View File
@@ -1,8 +1,10 @@
import { useState } from "react";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
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 {
@@ -17,12 +19,12 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
<Dialog.Text className="mb-6">
<DialogPanel>
<Title>Edit machine name for {machine.givenName}</Title>
<Text className="mb-6">
This name is shown in the admin panel, in Tailscale clients, and used when generating
MagicDNS names.
</Dialog.Text>
</Text>
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
@@ -79,7 +81,7 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
</p>
)
) : undefined}
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+12 -10
View File
@@ -1,10 +1,12 @@
import { GlobeLock, RouteOff } from "lucide-react";
import { useFetcher } from "react-router";
import Dialog 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 { PopulatedNode } from "~/utils/node-info";
interface RoutesProps {
@@ -24,16 +26,16 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
<DialogPanel variant="unactionable">
<Title>Edit route settings of {node.givenName}</Title>
<Text className="font-bold">Subnet routes</Text>
<Text>
Connect to devices you can&apos;t install Tailscale on by advertising IP ranges as subnet
routes.{" "}
<Link external styled to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</Dialog.Text>
</Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
@@ -62,13 +64,13 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
</TableList.Item>
))}
</TableList>
<Dialog.Text className="mt-8 font-bold">Exit nodes</Dialog.Text>
<Dialog.Text>
<Text className="mt-8 font-bold">Exit nodes</Text>
<Text>
Allow your network to route internet traffic through this machine.{" "}
<Link external styled to="https://tailscale.com/kb/1103/exit-nodes">
Learn More
</Link>
</Dialog.Text>
</Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
@@ -96,7 +98,7 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+9 -7
View File
@@ -2,11 +2,13 @@ import { Plus, TagsIcon, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/Button";
import Dialog from "~/components/Dialog";
import Button from "~/components/button";
import Dialog, { DialogPanel } from "~/components/Dialog";
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 type { Machine } from "~/types";
import cn from "~/utils/cn";
@@ -61,7 +63,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
setIsOpen(open);
}}
>
<Dialog.Panel
<DialogPanel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
@@ -73,14 +75,14 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
}}
isDisabled={fetcher.state !== "idle"}
>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
<Title>Edit ACL tags for {machine.givenName}</Title>
<Text>
ACL tags can be used to reference machines in your ACL policies. See the{" "}
<Link external styled to="https://tailscale.com/kb/1068/acl-tags">
Tailscale documentation
</Link>{" "}
for more information.
</Dialog.Text>
</Text>
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
@@ -138,7 +140,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
</p>
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { useMemo, useState } from "react";
import { data } from "react-router";
import Attribute from "~/components/Attribute";
import Button from "~/components/Button";
import Button from "~/components/button";
import Card from "~/components/Card";
import Chip from "~/components/Chip";
import Link from "~/components/link";
@@ -2,14 +2,16 @@ import type { Key } from "react";
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { User } from "~/types";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -91,11 +93,9 @@ export default function AddAuthKey({
Create pre-auth key
</Button>
{createdKey ? (
<Dialog.Panel variant="unactionable">
<Dialog.Title>Pre-auth key created</Dialog.Title>
<Dialog.Text>
Copy this key now. You will not be able to see the full key again.
</Dialog.Text>
<DialogPanel variant="unactionable">
<Title>Pre-auth key created</Title>
<Text>Copy this key now. You will not be able to see the full key again.</Text>
<div className="mt-4 flex items-center gap-2 rounded-lg bg-mist-100 px-3 py-2 dark:bg-mist-800">
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
<Button
@@ -109,13 +109,13 @@ export default function AddAuthKey({
Copy
</Button>
</div>
<Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
<Text className="mt-4 text-sm">To register a device with this key:</Text>
<Code isCopyable className="mt-1 block text-sm">
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
</Code>
</Dialog.Panel>
</DialogPanel>
) : (
<Dialog.Panel
<DialogPanel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
@@ -129,15 +129,13 @@ export default function AddAuthKey({
}}
isDisabled={fetcher.state !== "idle" || !canSubmit}
>
<Dialog.Title>Generate auth key</Dialog.Title>
<Title>Generate auth key</Title>
{!selfServiceOnly && (
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
<Text className="font-semibold">Tag-only key</Text>
<Text className="text-sm">Create a key owned by ACL tags instead of a user.</Text>
</div>
<Switch
defaultSelected={tagOnly}
@@ -193,10 +191,8 @@ export default function AddAuthKey({
/>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
<Text className="font-semibold">Reusable</Text>
<Text className="text-sm">Use this key to authenticate more than one device.</Text>
</div>
<Switch
defaultSelected={reusable}
@@ -206,14 +202,14 @@ export default function AddAuthKey({
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
<Text className="font-semibold">Ephemeral</Text>
<Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</Dialog.Text>
</Text>
</div>
<Switch
defaultSelected={ephemeral}
@@ -221,7 +217,7 @@ export default function AddAuthKey({
onChange={() => setEphemeral(!ephemeral)}
/>
</div>
</Dialog.Panel>
</DialogPanel>
)}
</Dialog>
);
@@ -1,25 +1,28 @@
import Dialog from '~/components/Dialog';
import type { PreAuthKey, User } from '~/types';
import Button from "~/components/Button";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { PreAuthKey, User } from "~/types";
interface ExpireAuthKeyProps {
authKey: PreAuthKey;
user: User;
authKey: PreAuthKey;
user: User;
}
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
return (
<Dialog>
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key" type="hidden" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately prevent it from
being used to authenticate new devices. This action cannot be undone.
</Dialog.Text>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button variant="heavy">Expire Key</Button>
<DialogPanel variant="destructive">
<Title>Expire auth key?</Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key" type="hidden" value={authKey.key} />
<Text>
Expiring this authentication key will immediately prevent it from being used to
authenticate new devices. This action cannot be undone.
</Text>
</DialogPanel>
</Dialog>
);
}
@@ -1,64 +1,68 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
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";
interface AddDomainProps {
domains: string[];
isDisabled?: boolean;
domains: string[];
isDisabled?: boolean;
}
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
const [domain, setDomain] = useState('');
const [domain, setDomain] = useState("");
const isInvalid = useMemo(() => {
if (!domain || domain.trim().length === 0) {
// Empty domain is invalid, but no error shown
return false;
}
const isInvalid = useMemo(() => {
if (!domain || domain.trim().length === 0) {
// Empty domain is invalid, but no error shown
return false;
}
if (domains.includes(domain.trim())) {
return true;
}
if (domains.includes(domain.trim())) {
return true;
}
try {
// Check if domain is a valid FQDN
const url = new URL(`http://${domain.trim()}`);
return url.hostname !== domain.trim();
} catch (e) {
// If URL constructor fails, it's not a valid domain
return true;
}
}, [domain, domains]);
try {
// Check if domain is a valid FQDN
const url = new URL(`http://${domain.trim()}`);
return url.hostname !== domain.trim();
} catch (e) {
// If URL constructor fails, it's not a valid domain
return true;
}
}, [domain, domains]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add domain</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add domain</Dialog.Title>
<Dialog.Text className="mb-4">
Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC.
</Dialog.Text>
<input name="action_id" type="hidden" value="add_domain" />
<Input
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.'
}
isInvalid={domain.trim().length === 0 || isInvalid}
isRequired
label="Domain"
name="domain"
onChange={setDomain}
placeholder="example.com"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The domain you entered is invalid or already exists in the list.
</p>
)}
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button isDisabled={isDisabled}>Add domain</Button>
<DialogPanel>
<Title>Add domain</Title>
<Text className="mb-4">
Add this domain to a list of allowed email domains that can authenticate with Headscale
via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_domain" />
<Input
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: "Enter a domain to match users with their email addresses."
}
isInvalid={domain.trim().length === 0 || isInvalid}
isRequired
label="Domain"
name="domain"
onChange={setDomain}
placeholder="example.com"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The domain you entered is invalid or already exists in the list.
</p>
)}
</DialogPanel>
</Dialog>
);
}
@@ -1,51 +1,54 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
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";
interface AddGroupProps {
groups: string[];
isDisabled?: boolean;
groups: string[];
isDisabled?: boolean;
}
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
const [group, setGroup] = useState('');
const [group, setGroup] = useState("");
const isInvalid = useMemo(() => {
if (!group || group.trim().length === 0) {
// Empty group is invalid, but no error shown
return false;
}
const isInvalid = useMemo(() => {
if (!group || group.trim().length === 0) {
// Empty group is invalid, but no error shown
return false;
}
if (groups.includes(group.trim())) {
return true;
}
}, [group, groups]);
if (groups.includes(group.trim())) {
return true;
}
}, [group, groups]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add group</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add group</Dialog.Title>
<Dialog.Text className="mb-4">
Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input name="action_id" type="hidden" value="add_group" />
<Input
description="The group to allow for OIDC authentication."
isInvalid={group.trim().length === 0 || isInvalid}
isRequired
label="Group"
name="group"
onChange={setGroup}
placeholder="admin"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The group you entered already exists in the list of allowed groups.
</p>
)}
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button isDisabled={isDisabled}>Add group</Button>
<DialogPanel>
<Title>Add group</Title>
<Text className="mb-4">
Add this group to a list of allowed groups that can authenticate with Headscale via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_group" />
<Input
description="The group to allow for OIDC authentication."
isInvalid={group.trim().length === 0 || isInvalid}
isRequired
label="Group"
name="group"
onChange={setGroup}
placeholder="admin"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The group you entered already exists in the list of allowed groups.
</p>
)}
</DialogPanel>
</Dialog>
);
}
@@ -1,51 +1,54 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
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";
interface AddUserProps {
users: string[];
isDisabled?: boolean;
users: string[];
isDisabled?: boolean;
}
export default function AddUser({ users, isDisabled }: AddUserProps) {
const [user, setUser] = useState('');
const [user, setUser] = useState("");
const isInvalid = useMemo(() => {
if (!user || user.trim().length === 0) {
// Empty user is invalid, but no error shown
return false;
}
const isInvalid = useMemo(() => {
if (!user || user.trim().length === 0) {
// Empty user is invalid, but no error shown
return false;
}
if (users.includes(user.trim())) {
return true;
}
}, [user, users]);
if (users.includes(user.trim())) {
return true;
}
}, [user, users]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add user</Dialog.Title>
<Dialog.Text className="mb-4">
Add this user to a list of allowed users that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input name="action_id" type="hidden" value="add_user" />
<Input
description="The user to allow for OIDC authentication."
isInvalid={user.trim().length === 0 || isInvalid}
isRequired
label="User"
name="user"
onChange={setUser}
placeholder="john_doe"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The user you entered already exists in the list of allowed users.
</p>
)}
</Dialog.Panel>
</Dialog>
);
return (
<Dialog>
<Button isDisabled={isDisabled}>Add user</Button>
<DialogPanel>
<Title>Add user</Title>
<Text className="mb-4">
Add this user to a list of allowed users that can authenticate with Headscale via OIDC.
</Text>
<input name="action_id" type="hidden" value="add_user" />
<Input
description="The user to allow for OIDC authentication."
isInvalid={user.trim().length === 0 || isInvalid}
isRequired
label="User"
name="user"
onChange={setUser}
placeholder="john_doe"
/>
{isInvalid && (
<p className="mt-2 text-sm text-red-500">
The user you entered already exists in the list of allowed users.
</p>
)}
</DialogPanel>
</Dialog>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { GlobeLock, Group, User2 } from "lucide-react";
import React from "react";
import { Form } from "react-router";
import Button from "~/components/Button";
import Button from "~/components/button";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
+10 -7
View File
@@ -1,5 +1,8 @@
import Dialog from "~/components/Dialog";
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";
interface CreateUserProps {
isOidc?: boolean;
@@ -9,15 +12,15 @@ interface CreateUserProps {
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Create a Headscale user</Dialog.Title>
<Dialog.Text className="mb-6">
<Button isDisabled={isDisabled}>Add user</Button>
<DialogPanel>
<Title>Create a Headscale user</Title>
<Text className="mb-6">
This creates a new user in Headscale. The user will appear in the &ldquo;Unlinked
Headscale Users&rdquo; section until they sign in
{isOidc ? " through your OIDC provider" : ""} and are automatically linked to a Headplane
account.
</Dialog.Text>
</Text>
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
@@ -54,7 +57,7 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
validationBehavior="native"
/>
</div>
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+10 -8
View File
@@ -1,4 +1,6 @@
import Dialog from "~/components/Dialog";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Text from "~/components/Text";
import Title from "~/components/Title";
import type { Machine, User } from "~/types";
interface DeleteProps {
@@ -13,15 +15,15 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Title>Delete {name}?</Dialog.Title>
<DialogPanel variant={machines.length > 0 ? "unactionable" : "normal"}>
<Title>Delete {name}?</Title>
{machines.length > 0 ? (
<Dialog.Text className="mb-6">
<Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or re-assign their machines
to other users before proceeding.
</Dialog.Text>
</Text>
) : (
<Dialog.Text className="mb-6">
<Text className="mb-6">
Deleted users cannot be recovered.
{user.provider === "oidc" && (
<p className="mt-4 text-sm text-mist-600 dark:text-mist-300">
@@ -29,11 +31,11 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
they sign in again.
</p>
)}
</Dialog.Text>
</Text>
)}
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+8 -6
View File
@@ -1,5 +1,7 @@
import Dialog from "~/components/Dialog";
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 {
@@ -21,12 +23,12 @@ export default function LinkUser({
}: LinkUserProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Link Headscale user for {displayName}</Dialog.Title>
<Dialog.Text className="mb-6">
<DialogPanel>
<Title>Link Headscale user for {displayName}</Title>
<Text className="mb-6">
Select which Headscale user this identity should be linked to. This controls which
machines they can manage and enables self-service features.
</Dialog.Text>
</Text>
{headscaleUsers.length === 0 ? (
<Notice>All Headscale users are already linked to other accounts.</Notice>
) : (
@@ -53,7 +55,7 @@ export default function LinkUser({
</select>
</>
)}
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+8 -6
View File
@@ -1,7 +1,9 @@
import Dialog 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 { Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
@@ -22,15 +24,15 @@ export default function ReassignUser({
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={role === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
<DialogPanel variant={role === "owner" ? "unactionable" : "normal"}>
<Title>Change role for {displayName}?</Title>
<Text className="mb-6">
Roles control what the user can access in Headplane. Each role grants a specific set of
capabilities.{" "}
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
Learn More
</Link>
</Dialog.Text>
</Text>
{role === "owner" ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
@@ -60,7 +62,7 @@ export default function ReassignUser({
</RadioGroup>
</>
)}
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}
+28 -27
View File
@@ -1,34 +1,35 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import { User } from '~/types';
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 {
user: User;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: User;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Server side validation before submitting
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a
username will not update any ACL policies that may refer to this user
by their old username.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<DialogPanel>
<Title>Rename {user.name || user.displayName}?</Title>
<Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a username will not
update any ACL policies that may refer to this user by their old username.
</Text>
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<Input
defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/>
</DialogPanel>
</Dialog>
);
}
@@ -1,5 +1,7 @@
import Dialog from "~/components/Dialog";
import Dialog, { DialogPanel } from "~/components/Dialog";
import Notice from "~/components/Notice";
import Text from "~/components/Text";
import Title from "~/components/Title";
interface TransferOwnershipProps {
targetUserId: string;
@@ -16,19 +18,19 @@ export default function TransferOwnership({
}: TransferOwnershipProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Transfer ownership to {targetDisplayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
<DialogPanel variant="destructive">
<Title>Transfer ownership to {targetDisplayName}?</Title>
<Text className="mb-6">
This will make {targetDisplayName} the new owner of this Headplane instance. You will be
demoted to an Admin. This action cannot be easily undone.
</Dialog.Text>
</Text>
<Notice variant="warning">
Only the owner can transfer ownership. After this, you will no longer be able to manage
ownership.
</Notice>
<input name="action_id" type="hidden" value="transfer_ownership" />
<input name="user_id" type="hidden" value={targetUserId} />
</Dialog.Panel>
</DialogPanel>
</Dialog>
);
}