feat: switch form fields to base-ui

This commit is contained in:
Aarnav Tale
2026-03-17 15:19:22 -04:00
parent 941971b8c7
commit 7403ebea06
75 changed files with 842 additions and 1086 deletions
+4 -4
View File
@@ -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";
+2 -2
View File
@@ -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";
+11 -12
View File
@@ -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>
);
}
+50 -53
View File
@@ -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>
);
}
}
+4 -4
View File
@@ -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"
+3 -3
View File
@@ -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"
+5 -5
View File
@@ -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",
+2 -2
View File
@@ -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";
+8 -8
View File
@@ -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"
+3 -3
View File
@@ -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;
+11 -11
View File
@@ -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}
+19 -18
View File
@@ -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">
+2 -2
View File
@@ -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
View File
@@ -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";
+3 -3
View File
@@ -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 {
+3 -3
View File
@@ -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 {
+14 -14
View File
@@ -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>
);
+18 -14
View File
@@ -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}>
+6 -36
View File
@@ -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 ? (
+9 -9
View File
@@ -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);
+12 -20
View File
@@ -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}
+5 -5
View File
@@ -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";
+3 -3
View File
@@ -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 {
+23 -25
View File
@@ -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";
+1 -1
View File
@@ -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 {
+3 -3
View File
@@ -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";
+7 -37
View File
@@ -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>
+3 -3
View File
@@ -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 {
+4 -4
View File
@@ -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 {
+6 -12
View File
@@ -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) => {
+5 -5
View File
@@ -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;