mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
feat: switch form fields to base-ui
This commit is contained in:
@@ -1,84 +0,0 @@
|
||||
import { Asterisk } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { type AriaTextFieldProps, useId, useTextField } from "react-aria";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const { label, labelHidden, className, isInvalid: customIsInvalid, errorMessage } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
const {
|
||||
labelProps,
|
||||
inputProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid: ariaIsInvalid,
|
||||
validationErrors,
|
||||
} = useTextField(
|
||||
{
|
||||
...props,
|
||||
label,
|
||||
"aria-label": label,
|
||||
},
|
||||
ref,
|
||||
);
|
||||
|
||||
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<label
|
||||
{...labelProps}
|
||||
className={cn(
|
||||
"text-xs font-medium px-3 mb-0.5",
|
||||
"text-mist-700 dark:text-mist-100",
|
||||
labelHidden && "sr-only",
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2",
|
||||
"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",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
required={props.isRequired}
|
||||
/>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid ? (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
|
||||
>
|
||||
{errorMessage ?? validationErrors.join(" ")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleSlash2,
|
||||
LucideProps,
|
||||
TriangleAlert,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: 'default' | 'error' | 'warning';
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({
|
||||
children,
|
||||
title,
|
||||
variant,
|
||||
icon,
|
||||
}: NoticeProps) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-2xl my-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? (
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
) : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
|
||||
switch (variant) {
|
||||
case 'error':
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case 'warning':
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { type AriaNumberFieldProps, useButton, useId, useLocale, useNumberField } from "react-aria";
|
||||
import { useNumberFieldState } from "react-stately";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends AriaNumberFieldProps {
|
||||
isRequired?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function NumberInput(props: InputProps) {
|
||||
const { label, name } = props;
|
||||
const { locale } = useLocale();
|
||||
const state = useNumberFieldState({ ...props, locale });
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const id = useId(props.id);
|
||||
|
||||
const {
|
||||
labelProps,
|
||||
inputProps,
|
||||
groupProps,
|
||||
incrementButtonProps,
|
||||
decrementButtonProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid,
|
||||
validationErrors,
|
||||
} = useNumberField(props, state, ref);
|
||||
|
||||
const decrRef = useRef<HTMLButtonElement | null>(null);
|
||||
const incrRef = useRef<HTMLButtonElement | null>(null);
|
||||
const { buttonProps: decrProps } = useButton(decrementButtonProps, decrRef);
|
||||
const { buttonProps: incrProps } = useButton(incrementButtonProps, incrRef);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<div
|
||||
{...groupProps}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md pr-1",
|
||||
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 focus:outline-hidden"
|
||||
/>
|
||||
<input type="hidden" name={name} value={state.numberValue} />
|
||||
<button
|
||||
{...decrProps}
|
||||
ref={decrRef}
|
||||
aria-label="Decrement"
|
||||
className="h-7.5 w-7.5 rounded-lg p-1"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
{...incrProps}
|
||||
ref={incrRef}
|
||||
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
|
||||
{...descriptionProps}
|
||||
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{isInvalid && (
|
||||
<div
|
||||
{...errorMessageProps}
|
||||
className={cn("text-xs px-3 mt-1", "text-red-500 dark:text-red-400")}
|
||||
>
|
||||
{validationErrors.join(" ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useRef } from "react";
|
||||
import { type AriaPopoverProps, DismissButton, Overlay, usePopover } from "react-aria";
|
||||
import type { OverlayTriggerState } from "react-stately";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface PopoverProps extends Omit<AriaPopoverProps, "popoverRef"> {
|
||||
children: React.ReactNode;
|
||||
state: OverlayTriggerState;
|
||||
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Popover(props: PopoverProps) {
|
||||
const ref = props.popoverRef ?? useRef<HTMLDivElement | null>(null);
|
||||
const { state, children, className } = props;
|
||||
const { popoverProps, underlayProps } = usePopover(
|
||||
{
|
||||
...props,
|
||||
popoverRef: ref,
|
||||
offset: 8,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div {...underlayProps} className="fixed inset-0" />
|
||||
<div
|
||||
{...popoverProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-10 shadow-overlay rounded-lg",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DismissButton onDismiss={state.close} />
|
||||
{children}
|
||||
<DismissButton onDismiss={state.close} />
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import React, { createContext, useContext, useRef } from "react";
|
||||
import { AriaRadioGroupProps, AriaRadioProps, VisuallyHidden, useFocusRing } from "react-aria";
|
||||
import { useRadio, useRadioGroup } from "react-aria";
|
||||
import { RadioGroupState } from "react-stately";
|
||||
import { useRadioGroupState } from "react-stately";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
children: React.ReactElement<RadioProps>[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
const state = useRadioGroupState(props);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
"aria-label": label,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={cn("flex flex-col gap-2", className)}>
|
||||
<VisuallyHidden>
|
||||
<span {...labelProps}>{label}</span>
|
||||
</VisuallyHidden>
|
||||
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioProps extends AriaRadioProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||
const state = useContext(RadioContext);
|
||||
const ref = useRef(null);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||
{
|
||||
...props,
|
||||
"aria-label": label,
|
||||
},
|
||||
state!,
|
||||
ref,
|
||||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"w-5 h-5 aspect-square rounded-full p-1 border-2",
|
||||
"border border-mist-600 dark:border-mist-300",
|
||||
isFocusVisible
|
||||
? "ring-2 ring-indigo-500/40 ring-offset-1 dark:ring-indigo-400/40 dark:ring-offset-mist-900"
|
||||
: "",
|
||||
isDisabled ? "opacity-50 cursor-not-allowed" : "",
|
||||
isSelected ? "border-[6px] border-mist-900 dark:border-mist-100" : "",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio });
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
AriaComboBoxProps,
|
||||
AriaListBoxOptions,
|
||||
useButton,
|
||||
useComboBox,
|
||||
useFilter,
|
||||
useId,
|
||||
useListBox,
|
||||
useOption,
|
||||
} from "react-aria";
|
||||
import { Item, ListState, Node, useComboBoxState } from "react-stately";
|
||||
|
||||
import Popover from "~/components/Popover";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SelectProps extends AriaComboBoxProps<object> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Select(props: SelectProps) {
|
||||
const { contains } = useFilter({ sensitivity: "base" });
|
||||
const state = useComboBoxState({ ...props, defaultFilter: contains });
|
||||
const id = useId(props.id);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const listBoxRef = useRef<HTMLUListElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const {
|
||||
buttonProps: triggerProps,
|
||||
inputProps,
|
||||
listBoxProps,
|
||||
labelProps,
|
||||
descriptionProps,
|
||||
} = useComboBox(
|
||||
{
|
||||
...props,
|
||||
inputRef,
|
||||
buttonRef,
|
||||
listBoxRef,
|
||||
popoverRef,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
const { buttonProps } = useButton(triggerProps, buttonRef);
|
||||
return (
|
||||
<div className={cn("flex flex-col", props.className)}>
|
||||
<label
|
||||
{...labelProps}
|
||||
className={cn("text-xs font-medium px-3 mb-0.5", "text-mist-700 dark:text-mist-100")}
|
||||
htmlFor={id}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex rounded-md focus:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-100 dark:border-mist-800",
|
||||
props.isInvalid && "ring-red-400",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
className="w-full rounded-l-md bg-transparent px-3 py-2 outline-hidden"
|
||||
data-1p-ignore
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button
|
||||
{...buttonProps}
|
||||
className={cn(
|
||||
"flex items-center justify-center p-1 rounded-lg m-1",
|
||||
"bg-mist-100 dark:bg-mist-700/30 font-medium",
|
||||
props.isDisabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-mist-200/90 dark:hover:bg-mist-800/30",
|
||||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
</button>
|
||||
</div>
|
||||
{props.description && (
|
||||
<div
|
||||
{...descriptionProps}
|
||||
className={cn("text-xs px-3 mt-1", "text-mist-500 dark:text-mist-400")}
|
||||
>
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
className="w-full max-w-xs"
|
||||
isNonModal
|
||||
placement="bottom start"
|
||||
popoverRef={popoverRef}
|
||||
state={state}
|
||||
triggerRef={inputRef}
|
||||
>
|
||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||
state: ListState<object>;
|
||||
}
|
||||
|
||||
function ListBox(props: ListBoxProps) {
|
||||
const { listBoxRef, state } = props;
|
||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
className="max-h-72 w-full overflow-auto pt-1 outline-hidden"
|
||||
ref={listBoxRef}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option item={item} key={item.key} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionProps {
|
||||
item: Node<unknown>;
|
||||
state: ListState<unknown>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionProps) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { optionProps, isDisabled, isSelected, isFocused } = useOption(
|
||||
{
|
||||
key: item.key,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
className={cn(
|
||||
"flex items-center justify-between",
|
||||
"py-2 px-3 mx-1 rounded-lg mb-1",
|
||||
"focus:outline-hidden select-none",
|
||||
isFocused || isSelected
|
||||
? "bg-mist-100/50 dark:bg-mist-800"
|
||||
: "hover:bg-mist-100/50 dark:hover:bg-mist-800",
|
||||
isDisabled && "text-mist-300 dark:text-mist-600",
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{item.rendered}
|
||||
{isSelected && <Check className="p-0.5" />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item });
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { AriaSwitchProps, VisuallyHidden, useFocusRing, useSwitch } from "react-aria";
|
||||
import { useToggleState } from "react-stately";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
const state = useToggleState(props);
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
const { focusProps, isFocusVisible } = useFocusRing();
|
||||
const { inputProps } = useSwitch(
|
||||
{
|
||||
...props,
|
||||
"aria-label": props.label,
|
||||
},
|
||||
state,
|
||||
ref,
|
||||
);
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-x-2">
|
||||
<VisuallyHidden elementType="span">
|
||||
<input {...inputProps} {...focusProps} aria-label={props.label} ref={ref} />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"flex h-[28px] w-[46px] p-[4px] shrink-0 rounded-full",
|
||||
"bg-mist-300 dark:bg-mist-700",
|
||||
"border border-transparent dark:border-mist-800",
|
||||
state.isSelected && "bg-mist-900 dark:bg-mist-950",
|
||||
isFocusVisible &&
|
||||
"ring-2 ring-indigo-500/40 ring-offset-1 dark:ring-indigo-400/40 dark:ring-offset-mist-900",
|
||||
props.isDisabled && "opacity-50",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"h-[18px] w-[18px] transform rounded-full",
|
||||
"bg-white transition duration-50 ease-in-out",
|
||||
"translate-x-0 group-selected:translate-x-full",
|
||||
state.isSelected && "translate-x-full",
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface TextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Text({ children, className }: TextProps) {
|
||||
return <p className={cn('text-md my-0', className)}>{children}</p>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from 'react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface TitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Title({ children, className }: TitleProps) {
|
||||
return (
|
||||
<h3 className={cn('text-2xl font-bold mb-2', className)}>{children}</h3>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Check, Copy, Info } from "lucide-react";
|
||||
import cn from "~/utils/cn";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
import Tooltip from "./Tooltip";
|
||||
import Tooltip from "./tooltip";
|
||||
|
||||
export interface AttributeProps {
|
||||
name: string;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props extends React.HTMLProps<HTMLDivElement> {
|
||||
@@ -4,8 +4,8 @@ import { isRouteErrorResponse } from "react-router";
|
||||
import { isApiError, isConnectionError } from "~/server/headscale/api/error-client";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Card from "./Card";
|
||||
import Code from "./Code";
|
||||
import Card from "./card";
|
||||
import Code from "./code";
|
||||
import Link from "./link";
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Field } from "@base-ui/react/field";
|
||||
import { Input as BaseInput } from "@base-ui/react/input";
|
||||
import { Asterisk } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface InputProps extends Omit<ComponentProps<typeof BaseInput>, "onChange"> {
|
||||
label: string;
|
||||
labelHidden?: boolean;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
description?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function Input(props: InputProps) {
|
||||
const {
|
||||
label,
|
||||
labelHidden,
|
||||
className,
|
||||
invalid,
|
||||
errorMessage,
|
||||
description,
|
||||
required,
|
||||
onChange,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Field.Root className={cn("flex w-full flex-col gap-1", className)} invalid={invalid}>
|
||||
<Field.Label
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
"text-mist-700 dark:text-mist-200",
|
||||
labelHidden && "sr-only",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{required && <Asterisk className="ml-0.5 inline w-3.5 pb-1 text-red-500" />}
|
||||
</Field.Label>
|
||||
<BaseInput
|
||||
{...rest}
|
||||
required={required}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm",
|
||||
"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",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
onChange={
|
||||
onChange
|
||||
? (e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{description && (
|
||||
<Field.Description className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>
|
||||
{description}
|
||||
</Field.Description>
|
||||
)}
|
||||
{invalid && errorMessage ? (
|
||||
<Field.Error className={cn("text-xs", "text-red-500 dark:text-red-400")}>
|
||||
{errorMessage}
|
||||
</Field.Error>
|
||||
) : null}
|
||||
</Field.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CircleAlert, CircleSlash2, LucideProps, TriangleAlert } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
|
||||
export interface NoticeProps {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
variant?: "default" | "error" | "warning";
|
||||
icon?: React.ReactElement<LucideProps>;
|
||||
}
|
||||
|
||||
export default function Notice({ children, title, variant, icon }: NoticeProps) {
|
||||
return (
|
||||
<Card variant="flat" className="my-6 max-w-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
{title ? <Card.Title className="mb-0 text-xl">{title}</Card.Title> : undefined}
|
||||
{!variant && icon ? icon : iconForVariant(variant)}
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForVariant(variant?: "default" | "error" | "warning") {
|
||||
switch (variant) {
|
||||
case "error":
|
||||
return <TriangleAlert className="text-red-500" />;
|
||||
case "warning":
|
||||
return <CircleAlert className="text-yellow-500" />;
|
||||
default:
|
||||
return <CircleSlash2 />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NumberField } from "@base-ui/react/number-field";
|
||||
import { Minus, Plus } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface NumberInputProps {
|
||||
label?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number | null) => void;
|
||||
}
|
||||
|
||||
export default function NumberInput(props: NumberInputProps) {
|
||||
const { label, name, description } = props;
|
||||
|
||||
return (
|
||||
<NumberField.Root
|
||||
className="flex flex-col gap-1"
|
||||
defaultValue={props.defaultValue}
|
||||
value={props.value}
|
||||
onValueChange={props.onValueChange}
|
||||
min={props.min}
|
||||
max={props.max}
|
||||
step={props.step}
|
||||
disabled={props.disabled}
|
||||
required={props.required}
|
||||
>
|
||||
{label && (
|
||||
<NumberField.ScrubArea>
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
<NumberField.ScrubAreaCursor />
|
||||
{label}
|
||||
</label>
|
||||
</NumberField.ScrubArea>
|
||||
)}
|
||||
<NumberField.Group
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md pr-1",
|
||||
"focus-within:outline-hidden focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<NumberField.Input
|
||||
name={name}
|
||||
className="w-full rounded-l-md bg-transparent py-2 pl-3 text-sm focus:outline-hidden"
|
||||
/>
|
||||
<NumberField.Decrement aria-label="Decrement" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Minus className="h-4 w-4" />
|
||||
</NumberField.Decrement>
|
||||
<NumberField.Increment aria-label="Increment" className="h-7.5 w-7.5 rounded-lg p-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
</NumberField.Increment>
|
||||
</NumberField.Group>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</NumberField.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Radio } from "@base-ui/react/radio";
|
||||
import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group";
|
||||
import type React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface RadioGroupProps {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
className?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string, eventDetails: BaseRadioGroup.ChangeEventDetails) => void;
|
||||
}
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={cn("flex flex-col gap-2.5", className)}
|
||||
>
|
||||
{children}
|
||||
</BaseRadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioItemProps {
|
||||
value: string;
|
||||
label: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function RadioItem({ children, label, className, value, disabled }: RadioItemProps) {
|
||||
return (
|
||||
<label className="flex items-center gap-2.5 text-sm">
|
||||
<Radio.Root
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"w-5 h-5 aspect-square rounded-full border-2",
|
||||
"border-mist-400 dark:border-mist-500",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
"data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed",
|
||||
"data-[checked]:border-[6px] data-[checked]:border-mist-900 dark:data-[checked]:border-mist-100",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Radio.Indicator />
|
||||
</Radio.Root>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio: RadioItem });
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Combobox } from "@base-ui/react/combobox";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
items: SelectItem[];
|
||||
label?: string;
|
||||
"aria-label"?: string;
|
||||
name?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
invalid?: boolean;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export default function Select({
|
||||
items,
|
||||
label,
|
||||
className,
|
||||
placeholder,
|
||||
description,
|
||||
required,
|
||||
disabled,
|
||||
invalid,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
name,
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const selectedItem =
|
||||
value !== undefined ? (items.find((i) => i.value === value) ?? null) : undefined;
|
||||
const defaultSelectedItem =
|
||||
defaultValue !== undefined ? (items.find((i) => i.value === defaultValue) ?? null) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{label && (
|
||||
<label className={cn("text-sm font-medium", "text-mist-700 dark:text-mist-200")}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Combobox.Root
|
||||
items={items}
|
||||
value={selectedItem}
|
||||
defaultValue={defaultSelectedItem}
|
||||
onValueChange={(item) => onValueChange?.(item?.value ?? null)}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
aria-label={props["aria-label"]}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-md",
|
||||
"focus-within:ring-2 focus-within:ring-indigo-500/40 focus-within:ring-offset-1",
|
||||
"dark:focus-within:ring-indigo-400/40 dark:focus-within:ring-offset-mist-900",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
invalid && "ring-red-400",
|
||||
)}
|
||||
>
|
||||
<Combobox.Input
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
className="w-full rounded-md bg-transparent px-3 py-2 pr-9 text-sm outline-hidden"
|
||||
data-1p-ignore
|
||||
/>
|
||||
<Combobox.Trigger
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 flex items-center pr-3",
|
||||
"text-mist-400 dark:text-mist-500",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:text-mist-600 dark:hover:text-mist-300",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Combobox.Trigger>
|
||||
</div>
|
||||
<Combobox.Portal>
|
||||
<Combobox.Positioner
|
||||
className="z-50"
|
||||
sideOffset={8}
|
||||
style={{ width: "var(--anchor-width)" }}
|
||||
>
|
||||
<Combobox.Popup
|
||||
className={cn(
|
||||
"max-h-72 w-full overflow-auto rounded-lg p-1",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
"shadow-overlay",
|
||||
)}
|
||||
>
|
||||
<Combobox.Empty className="px-3 py-2 text-sm text-mist-500 empty:hidden">
|
||||
No results found.
|
||||
</Combobox.Empty>
|
||||
<Combobox.List>
|
||||
{(item: SelectItem) => (
|
||||
<Combobox.Item
|
||||
key={item.value}
|
||||
value={item}
|
||||
className={cn(
|
||||
"flex items-center justify-between text-sm",
|
||||
"py-1.5 px-2.5 rounded-md",
|
||||
"outline-hidden select-none cursor-default",
|
||||
"data-[highlighted]:bg-mist-100 dark:data-[highlighted]:bg-mist-800",
|
||||
"data-[selected]:font-medium",
|
||||
"data-[disabled]:text-mist-300 dark:data-[disabled]:text-mist-600",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
<Combobox.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
</Combobox.Popup>
|
||||
</Combobox.Positioner>
|
||||
</Combobox.Portal>
|
||||
</Combobox.Root>
|
||||
{description && (
|
||||
<div className={cn("text-xs", "text-mist-500 dark:text-mist-400")}>{description}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Switch as BaseSwitch } from "@base-ui/react/switch";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface SwitchProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
switchClassName?: string;
|
||||
name?: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
defaultChecked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Switch(props: SwitchProps) {
|
||||
return (
|
||||
<BaseSwitch.Root
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
className={cn(
|
||||
"flex h-[22px] w-[38px] p-[3px] shrink-0 rounded-full",
|
||||
"bg-mist-300 dark:bg-mist-700",
|
||||
"border border-transparent dark:border-mist-800",
|
||||
"data-[checked]:bg-mist-900 dark:data-[checked]:bg-mist-950",
|
||||
"focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-1 dark:focus-visible:ring-indigo-400/40 dark:focus-visible:ring-offset-mist-900",
|
||||
props.disabled && "opacity-50",
|
||||
props.className,
|
||||
)}
|
||||
defaultChecked={props.defaultChecked}
|
||||
disabled={props.disabled}
|
||||
name={props.name}
|
||||
onCheckedChange={props.onCheckedChange}
|
||||
>
|
||||
<BaseSwitch.Thumb
|
||||
className={cn(
|
||||
"h-[14px] w-[14px] transform rounded-full",
|
||||
"bg-white transition duration-50 ease-in-out",
|
||||
"translate-x-0 data-[checked]:translate-x-full",
|
||||
props.switchClassName,
|
||||
)}
|
||||
/>
|
||||
</BaseSwitch.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExitNodeTagProps {
|
||||
isEnabled?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this
|
||||
from the "Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Exit Node"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine is acting as an exit node.</>
|
||||
) : (
|
||||
<>
|
||||
This machine is requesting to be used as an exit node. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Chip from "../Chip";
|
||||
import Tooltip from "../Tooltip";
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface ExpiryTagProps {
|
||||
variant: "expired" | "no-expiry";
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function HeadplaneAgentTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn(
|
||||
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine is running the Headplane agent, which allows it to provide
|
||||
host information in the web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Headplane Agent"
|
||||
className={cn("bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300")}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine is running the Headplane agent, which allows it to provide host information in
|
||||
the web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export interface SubnetTagProps {
|
||||
isEnabled?: boolean;
|
||||
isEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function SubnetTag({ isEnabled }: SubnetTagProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn(
|
||||
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
|
||||
)}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the
|
||||
"Edit route settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Subnets"
|
||||
className={cn("bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300")}
|
||||
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
{isEnabled ? (
|
||||
<>This machine advertises subnet routes.</>
|
||||
) : (
|
||||
<>
|
||||
This machine has unadvertised subnet routes. Review this from the "Edit route
|
||||
settings..." option in the machine's menu.
|
||||
</>
|
||||
)}
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import cn from '~/utils/cn';
|
||||
import Chip from '../Chip';
|
||||
import Tooltip from '../Tooltip';
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Chip from "../chip";
|
||||
import Tooltip from "../tooltip";
|
||||
|
||||
export function TailscaleSSHTag() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn(
|
||||
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
|
||||
)}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine advertises Tailscale SSH, which allows you to authenticate
|
||||
SSH credentials using your Tailscale account and via the Headplane web
|
||||
UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<Chip
|
||||
text="Tailscale SSH"
|
||||
className={cn("bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500")}
|
||||
/>
|
||||
<Tooltip.Body>
|
||||
This machine advertises Tailscale SSH, which allows you to authenticate SSH credentials
|
||||
using your Tailscale account and via the Headplane web UI.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Text({ children, className }: TextProps) {
|
||||
return <p className={cn("text-md my-0", className)}>{children}</p>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
export interface TitleProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Title({ children, className }: TitleProps) {
|
||||
return <h3 className={cn("text-2xl font-bold mb-2", className)}>{children}</h3>;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Card from "~/components/card";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkAccountProps {
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useEffect, useState } from "react";
|
||||
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Code from "~/components/Code";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/Notice";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import Tabs from "~/components/Tabs";
|
||||
import Tabs from "~/components/tabs";
|
||||
import { isApiError } from "~/server/headscale/api/error-client";
|
||||
import toast from "~/utils/toast";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AlertCircle, CloudOff } from "lucide-react";
|
||||
|
||||
import Card from "~/components/Card";
|
||||
import Code from "~/components/Code";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Link from "~/components/link";
|
||||
import type { OidcConnectorError } from "~/server/web/oidc-connector";
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import Card from '~/components/Card';
|
||||
import Card from "~/components/card";
|
||||
|
||||
export default function Logout() {
|
||||
return (
|
||||
<div className="flex w-screen h-screen items-center justify-center">
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>You have been logged out</Card.Title>
|
||||
<Card.Text>
|
||||
You can now close this window. If you would like to log in again,
|
||||
please refresh the page.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<Card className="m-4 max-w-md sm:m-0">
|
||||
<Card.Title>You have been logged out</Card.Title>
|
||||
<Card.Text>
|
||||
You can now close this window. If you would like to log in again, please refresh the page.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,60 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
|
||||
export function OidcErrorNotice({ code }: { code: string }) {
|
||||
return (
|
||||
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{getErrorMessage(code)}
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
{getErrorMessage(code)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(code: string) {
|
||||
switch (code) {
|
||||
case 'error_no_query':
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not correctly redirect back to Headplane with the
|
||||
required parameters. Please ensure your SSO provider is configured
|
||||
correctly.
|
||||
</Card.Text>
|
||||
);
|
||||
switch (code) {
|
||||
case "error_no_query":
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not correctly redirect back to Headplane with the required
|
||||
parameters. Please ensure your SSO provider is configured correctly.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_no_session':
|
||||
case 'error_invalid_session':
|
||||
return (
|
||||
<Card.Text>
|
||||
Unable to complete SSO login due to missing or invalid session data.
|
||||
Ensure that your Headplane cookie configuration is correct and that
|
||||
your browser is accepting cookies.
|
||||
</Card.Text>
|
||||
);
|
||||
case "error_no_session":
|
||||
case "error_invalid_session":
|
||||
return (
|
||||
<Card.Text>
|
||||
Unable to complete SSO login due to missing or invalid session data. Ensure that your
|
||||
Headplane cookie configuration is correct and that your browser is accepting cookies.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_no_sub':
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not return a valid user identifier. Please ensure
|
||||
your SSO provider is correctly configured to provide the{' '}
|
||||
<Code>sub</Code> claim.
|
||||
</Card.Text>
|
||||
);
|
||||
case "error_no_sub":
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not return a valid user identifier. Please ensure your SSO provider
|
||||
is correctly configured to provide the <Code>sub</Code> claim.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_auth_failed':
|
||||
return (
|
||||
<Card.Text>
|
||||
Authentication with the SSO provider failed. Please try again later.
|
||||
Headplane logs may provide more information.
|
||||
</Card.Text>
|
||||
);
|
||||
case "error_auth_failed":
|
||||
return (
|
||||
<Card.Text>
|
||||
Authentication with the SSO provider failed. Please try again later. Headplane logs may
|
||||
provide more information.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Card.Text>
|
||||
An unknown error occurred during OIDC authentication. Please try again
|
||||
later.
|
||||
</Card.Text>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return (
|
||||
<Card.Text>
|
||||
An unknown error occurred during OIDC authentication. Please try again later.
|
||||
</Card.Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useEffect, useState } from "react";
|
||||
import { Form, redirect, useSearchParams } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Code from "~/components/Code";
|
||||
import Input from "~/components/Input";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
</Card.Text>
|
||||
<Input
|
||||
className="mt-8 mb-2"
|
||||
isRequired
|
||||
required
|
||||
label="API Key"
|
||||
labelHidden
|
||||
name="api_key"
|
||||
|
||||
@@ -12,8 +12,8 @@ import { useEffect, useState } from "react";
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Input from "~/components/Input";
|
||||
import TableList from "~/components/TableList";
|
||||
import Input from "~/components/input";
|
||||
import TableList from "~/components/table-list";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props {
|
||||
@@ -103,7 +103,7 @@ export default function ManageDomains({ searchDomains, isDisabled, magic }: Prop
|
||||
"border-none font-mono p-0 text-sm",
|
||||
"rounded-none focus:ring-0 w-full ml-1",
|
||||
)}
|
||||
isRequired
|
||||
required
|
||||
label="Search Domain"
|
||||
labelHidden
|
||||
name="domain"
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Form, useSubmit } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Link from "~/components/link";
|
||||
import Switch from "~/components/Switch";
|
||||
import TableList from "~/components/TableList";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Switch from "~/components/switch";
|
||||
import TableList from "~/components/table-list";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import AddNS from "../dialogs/add-ns";
|
||||
@@ -81,10 +81,10 @@ function NameserverList({ isGlobal, isDisabled, nameservers, overrideLocalDns, n
|
||||
<p>Override DNS servers</p>
|
||||
<Switch
|
||||
className="h-[15px] w-[23px] p-0.5"
|
||||
defaultSelected={overrideLocalDns}
|
||||
defaultChecked={overrideLocalDns}
|
||||
label="Override local DNS settings"
|
||||
name="override_dns"
|
||||
onChange={(v) => {
|
||||
onCheckedChange={(v) => {
|
||||
submit(
|
||||
{
|
||||
action_id: "override_dns",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Code from "~/components/Code";
|
||||
import Code from "~/components/code";
|
||||
import Link from "~/components/link";
|
||||
import TableList from "~/components/TableList";
|
||||
import TableList from "~/components/table-list";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import AddRecord from "../dialogs/add-record";
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Button from "~/components/button";
|
||||
import Code from "~/components/Code";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Code from "~/components/code";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@@ -20,11 +20,11 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
</p>
|
||||
<Input
|
||||
className="w-3/5 text-sm font-medium"
|
||||
isReadOnly
|
||||
readOnly
|
||||
label="Tailnet name"
|
||||
labelHidden
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
(event.target as HTMLInputElement).select();
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
@@ -39,7 +39,7 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
<input name="action_id" type="hidden" value="rename_tailnet" />
|
||||
<Input
|
||||
defaultValue={name}
|
||||
isRequired
|
||||
required
|
||||
label="Tailnet name"
|
||||
name="new_name"
|
||||
placeholder="ts.net"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface Props {
|
||||
isEnabled: boolean;
|
||||
|
||||
@@ -2,13 +2,13 @@ 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 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 {
|
||||
@@ -42,8 +42,8 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
<input name="action_id" type="hidden" value="add_ns" />
|
||||
<Input
|
||||
description="Use this IPv4 or IPv6 address to resolve names."
|
||||
isInvalid={isInvalid}
|
||||
isRequired
|
||||
invalid={isInvalid}
|
||||
required
|
||||
label="Nameserver"
|
||||
name="ns"
|
||||
onChange={setNs}
|
||||
@@ -67,13 +67,13 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
</div>
|
||||
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
|
||||
</div>
|
||||
<Switch label="Split DNS" onChange={setSplit} />
|
||||
<Switch label="Split DNS" onCheckedChange={setSplit} />
|
||||
</div>
|
||||
{split ? (
|
||||
<>
|
||||
<Text className="mt-8 font-semibold">Domain</Text>
|
||||
<Input
|
||||
isRequired={split === true}
|
||||
required={split === true}
|
||||
label="Domain"
|
||||
name="split_name"
|
||||
onChange={setDomain}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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";
|
||||
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 }[];
|
||||
@@ -39,32 +39,33 @@ export default function AddRecord({ records }: Props) {
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<input type="hidden" name="action_id" value="add_record" />
|
||||
<Select
|
||||
isRequired
|
||||
required
|
||||
label="Record Type"
|
||||
name="record_type"
|
||||
defaultInputValue={type}
|
||||
onSelectionChange={(v) => {
|
||||
if (v) setType(v.toString() as "A" | "AAAA");
|
||||
defaultValue={type}
|
||||
onValueChange={(v) => {
|
||||
if (v) setType(v as "A" | "AAAA");
|
||||
}}
|
||||
>
|
||||
<Select.Item key="A">A</Select.Item>
|
||||
<Select.Item key="AAAA">AAAA</Select.Item>
|
||||
</Select>
|
||||
items={[
|
||||
{ value: "A", label: "A" },
|
||||
{ value: "AAAA", label: "AAAA" },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
required
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="record_name"
|
||||
onChange={setName}
|
||||
isInvalid={isDuplicate}
|
||||
invalid={isDuplicate}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
required
|
||||
label="IP Address"
|
||||
placeholder={type === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
|
||||
name="record_value"
|
||||
onChange={setIp}
|
||||
isInvalid={isDuplicate}
|
||||
invalid={isDuplicate}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-sm opacity-50">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Notice from "~/components/Notice";
|
||||
import Code from "~/components/code";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import type { LoadContext } from "~/server";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import linuxSvg from "~/assets/linux.svg";
|
||||
import macosSvg from "~/assets/macos.svg";
|
||||
import windowsSvg from "~/assets/windows.svg";
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Card from "~/components/card";
|
||||
import Link from "~/components/link";
|
||||
import LinkAccount from "~/layout/link-account";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ChevronDown, Copy } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import Chip from "~/components/Chip";
|
||||
import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import { ExitNodeTag } from "~/components/tags/ExitNode";
|
||||
import { ExpiryTag } from "~/components/tags/Expiry";
|
||||
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
interface DeleteProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
interface ExpireProps {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Key, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Select from "~/components/Select";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Select from "~/components/select";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine, User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
@@ -15,7 +15,7 @@ interface MoveProps {
|
||||
}
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
|
||||
const [userId, setUserId] = useState<string | null>(machine.user?.id ?? null);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -26,19 +26,19 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user?.id}
|
||||
isRequired
|
||||
defaultValue={machine.user?.id}
|
||||
required
|
||||
label="Owner"
|
||||
name="user"
|
||||
onSelectionChange={(key) => {
|
||||
onValueChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
items={users.map((user) => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Computer, FileKey2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Code from "~/components/code";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
|
||||
import Select from "~/components/Select";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Select from "~/components/select";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
@@ -38,19 +38,23 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
errorMessage="Machine key must be exactly 24 characters"
|
||||
isInvalid={isMkeyInvalid}
|
||||
isRequired
|
||||
invalid={isMkeyInvalid}
|
||||
required
|
||||
label="Machine Key"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Select isRequired label="Owner" name="user" placeholder="Select a user">
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
required
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
items={data.users.map((user) => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
<Menu disabled={data.isDisabled}>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Code from "~/components/code";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
interface RenameProps {
|
||||
@@ -29,41 +29,11 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<Input
|
||||
defaultValue={machine.givenName}
|
||||
isRequired
|
||||
required
|
||||
label="Machine name"
|
||||
name="name"
|
||||
onChange={setName}
|
||||
placeholder="Machine name"
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return "Cannot be empty";
|
||||
}
|
||||
|
||||
// DNS hostname validation
|
||||
if (value.toLowerCase() !== value) {
|
||||
return "Cannot contain uppercase letters";
|
||||
}
|
||||
|
||||
if (value.length > 63) {
|
||||
return "DNS hostnames cannot be 64+ characters";
|
||||
}
|
||||
|
||||
// Test for invalid characters
|
||||
if (!/^[a-z0-9-]+$/.test(value)) {
|
||||
return "Cannot contain special characters";
|
||||
}
|
||||
|
||||
// Test for leading/trailing hyphens
|
||||
if (value.startsWith("-") || value.endsWith("-")) {
|
||||
return "Cannot start or end with a hyphen";
|
||||
}
|
||||
|
||||
// Test for consecutive hyphens
|
||||
if (value.includes("--")) {
|
||||
return "Cannot contain consecutive hyphens";
|
||||
}
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { GlobeLock, RouteOff } from "lucide-react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Link from "~/components/link";
|
||||
import Switch from "~/components/Switch";
|
||||
import TableList from "~/components/TableList";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Switch from "~/components/switch";
|
||||
import TableList from "~/components/table-list";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { PopulatedNode } from "~/utils/node-info";
|
||||
|
||||
interface RoutesProps {
|
||||
@@ -47,9 +47,9 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
<TableList.Item key={route}>
|
||||
<p>{route}</p>
|
||||
<Switch
|
||||
defaultSelected={node.approvedRoutes.includes(route)}
|
||||
defaultChecked={node.approvedRoutes.includes(route)}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set("action_id", "update_routes");
|
||||
form.set("node_id", node.id);
|
||||
@@ -81,9 +81,9 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
<TableList.Item>
|
||||
<p>Use as exit node</p>
|
||||
<Switch
|
||||
defaultSelected={node.customRouting.exitApproved}
|
||||
defaultChecked={node.customRouting.exitApproved}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
onCheckedChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set("action_id", "update_routes");
|
||||
form.set("node_id", node.id);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import Select from "~/components/Select";
|
||||
import TableList from "~/components/TableList";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import TableList from "~/components/table-list";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
@@ -29,11 +29,6 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
[tag, tags],
|
||||
);
|
||||
|
||||
const validNodeTags = useMemo(
|
||||
() => existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [],
|
||||
[tags],
|
||||
);
|
||||
|
||||
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -112,19 +107,16 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
|
||||
</TableList>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Select
|
||||
allowsCustomValue
|
||||
<Input
|
||||
aria-label="Add a tag"
|
||||
className="w-full"
|
||||
inputValue={tag}
|
||||
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||
onInputChange={setTag}
|
||||
value={tag}
|
||||
onChange={setTag}
|
||||
invalid={tag.length > 0 && tagIsInvalid}
|
||||
placeholder="tag:example"
|
||||
>
|
||||
{validNodeTags.map((nodeTag) => (
|
||||
<Select.Item key={nodeTag}>{nodeTag}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
label="Tag"
|
||||
labelHidden
|
||||
/>
|
||||
<Button
|
||||
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
||||
disabled={tagIsInvalid}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { data } from "react-router";
|
||||
|
||||
import Attribute from "~/components/Attribute";
|
||||
import Attribute from "~/components/attribute";
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Chip from "~/components/Chip";
|
||||
import Card from "~/components/card";
|
||||
import Chip from "~/components/chip";
|
||||
import Link from "~/components/link";
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Input from "~/components/Input";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
import PageError from "~/components/page-error";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import Tooltip from "~/components/tooltip";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Attribute from "~/components/Attribute";
|
||||
import Attribute from "~/components/attribute";
|
||||
import type { PreAuthKey, User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Key } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Code from "~/components/Code";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Code from "~/components/code";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import 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 NumberInput from "~/components/number-input";
|
||||
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";
|
||||
@@ -49,7 +48,7 @@ export default function AddAuthKey({
|
||||
const [tagOnly, setTagOnly] = useState(false);
|
||||
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
||||
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
|
||||
const [userId, setUserId] = useState<string | null>(availableUsers[0]?.id);
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
|
||||
@@ -138,9 +137,9 @@ export default function AddAuthKey({
|
||||
<Text className="text-sm">Create a key owned by ACL tags instead of a user.</Text>
|
||||
</div>
|
||||
<Switch
|
||||
defaultSelected={tagOnly}
|
||||
defaultChecked={tagOnly}
|
||||
label="Tag-only"
|
||||
onChange={() => setTagOnly(!tagOnly)}
|
||||
onCheckedChange={() => setTagOnly(!tagOnly)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -153,23 +152,23 @@ export default function AddAuthKey({
|
||||
? "You can only create keys for your own user."
|
||||
: "Machines will belong to this user when they authenticate."
|
||||
}
|
||||
isDisabled={selfServiceOnly}
|
||||
isRequired
|
||||
disabled={selfServiceOnly}
|
||||
required
|
||||
label="User"
|
||||
onSelectionChange={(value) => setUserId(value)}
|
||||
onValueChange={(value) => setUserId(value)}
|
||||
placeholder="Select a user"
|
||||
selectedKey={userId}
|
||||
>
|
||||
{availableUsers.map((user) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
value={userId}
|
||||
items={availableUsers.map((user) => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
className="mb-2"
|
||||
description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
|
||||
isRequired={tagOnly}
|
||||
required={tagOnly}
|
||||
label="ACL Tags"
|
||||
onChange={(value) => setTags(value)}
|
||||
placeholder="server, prod"
|
||||
@@ -178,15 +177,10 @@ export default function AddAuthKey({
|
||||
<NumberInput
|
||||
defaultValue={90}
|
||||
description="Set this key to expire after a certain number of days."
|
||||
formatOptions={{
|
||||
style: "unit",
|
||||
unit: "day",
|
||||
unitDisplay: "short",
|
||||
}}
|
||||
isRequired
|
||||
required
|
||||
label="Key Expiration"
|
||||
maxValue={365_000}
|
||||
minValue={1}
|
||||
max={365_000}
|
||||
min={1}
|
||||
name="expiry"
|
||||
/>
|
||||
<div className="mt-6 flex items-center justify-between gap-2">
|
||||
@@ -195,9 +189,9 @@ export default function AddAuthKey({
|
||||
<Text className="text-sm">Use this key to authenticate more than one device.</Text>
|
||||
</div>
|
||||
<Switch
|
||||
defaultSelected={reusable}
|
||||
defaultChecked={reusable}
|
||||
label="Reusable"
|
||||
onChange={() => setReusable(!reusable)}
|
||||
onCheckedChange={() => setReusable(!reusable)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between gap-2">
|
||||
@@ -212,9 +206,9 @@ export default function AddAuthKey({
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
defaultSelected={ephemeral}
|
||||
defaultChecked={ephemeral}
|
||||
label="Ephemeral"
|
||||
onChange={() => setEphemeral(!ephemeral)}
|
||||
onCheckedChange={() => setEphemeral(!ephemeral)}
|
||||
/>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { PreAuthKey, User } from "~/types";
|
||||
|
||||
interface ExpireAuthKeyProps {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FileKey2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Code from "~/components/code";
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/Notice";
|
||||
import Select from "~/components/Select";
|
||||
import TableList from "~/components/TableList";
|
||||
import Notice from "~/components/notice";
|
||||
import Select from "~/components/select";
|
||||
import TableList from "~/components/table-list";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
@@ -207,38 +207,36 @@ export default function Page({
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
defaultValue="__headplane_all"
|
||||
disabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
|
||||
onValueChange={(value) => setSelectedUser(value ?? "")}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
items={[
|
||||
{ value: "__headplane_all", label: "All" },
|
||||
...keys
|
||||
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
|
||||
.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
)),
|
||||
.map(({ user }) => ({ value: user.id, label: getUserDisplayName(user) })),
|
||||
...(keys.some(({ user }) => user === null)
|
||||
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
|
||||
? [{ value: "__headplane_tag_only", label: "Tag Only" }]
|
||||
: []),
|
||||
]}
|
||||
</Select>
|
||||
/>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
defaultValue="active"
|
||||
disabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
|
||||
onValueChange={(value) => setStatus((value ?? "active") as Status)}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
items={[
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "expired", label: "Used/Expired" },
|
||||
{ value: "reusable", label: "Reusable" },
|
||||
{ value: "ephemeral", label: "Ephemeral" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface AddDomainProps {
|
||||
domains: string[];
|
||||
@@ -50,8 +50,8 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
||||
? `Matches users with <user>@${domain.trim()}`
|
||||
: "Enter a domain to match users with their email addresses."
|
||||
}
|
||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||
isRequired
|
||||
invalid={domain.trim().length === 0 || isInvalid}
|
||||
required
|
||||
label="Domain"
|
||||
name="domain"
|
||||
onChange={setDomain}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface AddGroupProps {
|
||||
groups: string[];
|
||||
@@ -36,8 +36,8 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
||||
<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
|
||||
invalid={group.trim().length === 0 || isInvalid}
|
||||
required
|
||||
label="Group"
|
||||
name="group"
|
||||
onChange={setGroup}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface AddUserProps {
|
||||
users: string[];
|
||||
@@ -36,8 +36,8 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
|
||||
<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
|
||||
invalid={user.trim().length === 0 || isInvalid}
|
||||
required
|
||||
label="User"
|
||||
name="user"
|
||||
onChange={setUser}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/Notice";
|
||||
import Notice from "~/components/notice";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from "react";
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import TableList from "~/components/TableList";
|
||||
import TableList from "~/components/table-list";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface RestrictionProps {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/Card";
|
||||
import Code from "~/components/Code";
|
||||
import Input from "~/components/Input";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
|
||||
interface UserPromptProps {
|
||||
hostname: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import StatusCircle from "~/components/status-circle";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Button from "~/components/button";
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface CreateUserProps {
|
||||
isOidc?: boolean;
|
||||
@@ -23,39 +23,9 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="Username"
|
||||
name="username"
|
||||
placeholder="my-new-user"
|
||||
type="text"
|
||||
validate={(value) => {
|
||||
if (value.trim().length === 0) {
|
||||
return "Username is required";
|
||||
}
|
||||
|
||||
if (value.includes(" ")) {
|
||||
return "Usernames cannot contain spaces";
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Display Name"
|
||||
name="display_name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input required label="Username" name="username" placeholder="my-new-user" type="text" />
|
||||
<Input label="Display Name" name="display_name" placeholder="John Doe" type="text" />
|
||||
<Input label="Email" name="email" placeholder="name@example.com" type="email" />
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import type { Machine, User } from "~/types";
|
||||
|
||||
interface DeleteProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Notice from "~/components/Notice";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Notice from "~/components/notice";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Link from "~/components/link";
|
||||
import Notice from "~/components/Notice";
|
||||
import RadioGroup from "~/components/RadioGroup";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Notice from "~/components/notice";
|
||||
import RadioGroup from "~/components/radio-group";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
@@ -39,13 +39,7 @@ export default function ReassignUser({
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<RadioGroup
|
||||
className="gap-4"
|
||||
defaultValue={role}
|
||||
isRequired
|
||||
label="Role"
|
||||
name="new_role"
|
||||
>
|
||||
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
|
||||
{Object.keys(Roles)
|
||||
.filter((r) => r !== "owner")
|
||||
.map((r) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Input from "~/components/input";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
import { User } from "~/types";
|
||||
|
||||
interface RenameProps {
|
||||
@@ -24,7 +24,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<Input
|
||||
defaultValue={user.name}
|
||||
isRequired
|
||||
required
|
||||
label="Username"
|
||||
name="new_name"
|
||||
placeholder="my-new-name"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Dialog, { DialogPanel } from "~/components/Dialog";
|
||||
import Notice from "~/components/Notice";
|
||||
import Text from "~/components/Text";
|
||||
import Title from "~/components/Title";
|
||||
import Dialog, { DialogPanel } from "~/components/dialog";
|
||||
import Notice from "~/components/notice";
|
||||
import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface TransferOwnershipProps {
|
||||
targetUserId: string;
|
||||
|
||||
Reference in New Issue
Block a user