mirror of
https://github.com/tale/headplane.git
synced 2026-08-28 16:07:07 +00:00
feat: swap button and decompose dialog components
This commit is contained in:
@@ -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,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 { 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
|
||||||
|
|||||||
@@ -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 { 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",
|
||||||
@@ -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 { 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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,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,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";
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,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";
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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't install Tailscale on by advertising IP ranges as subnet
|
Connect to devices you can'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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 “Unlinked
|
This creates a new user in Headscale. The user will appear in the “Unlinked
|
||||||
Headscale Users” section until they sign in
|
Headscale Users” 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user