mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: swap button and decompose dialog components
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 “Unlinked
|
||||
Headscale Users” 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user