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