feat: switch to new dialog across all code

This commit is contained in:
Aarnav Tale 2025-01-26 15:04:13 -05:00
parent 0f75636342
commit 741f9aa6b5
No known key found for this signature in database
24 changed files with 833 additions and 1196 deletions

View File

@ -21,10 +21,12 @@ import Title from '~/components/Title';
import { cn } from '~/utils/cn';
export interface DialogProps extends OverlayTriggerProps {
children: [
children:
| [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<DialogPanelProps>,
];
]
| React.ReactElement<DialogPanelProps>;
}
function Dialog(props: DialogProps) {
@ -36,6 +38,7 @@ function Dialog(props: DialogProps) {
state,
);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
@ -50,20 +53,38 @@ function Dialog(props: DialogProps) {
)}
</>
);
}
return (
<DModal state={state}>
{cloneElement(props.children, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
);
}
export interface DialogPanelProps extends AriaDialogProps {
children: React.ReactNode;
variant?: 'normal' | 'destructive';
variant?: 'normal' | 'destructive' | 'unactionable';
onSubmit?: React.FormEventHandler<HTMLFormElement>;
method?: HTMLFormMethod;
isDisabled?: boolean;
// Anonymous (passed by parent)
close?: () => void;
}
function Panel(props: DialogPanelProps) {
const { children, onSubmit, close, variant, method = 'POST' } = props;
const {
children,
onSubmit,
isDisabled,
close,
variant,
method = 'POST',
} = props;
const ref = useRef<HTMLFormElement | null>(null);
const { dialogProps } = useDialog(
{
@ -93,14 +114,22 @@ function Panel(props: DialogPanelProps) {
<Card className="w-full max-w-lg">
{children}
<div className="mt-6 flex justify-end gap-4">
{variant === 'unactionable' ? (
<Button onPress={close}>Close</Button>
) : (
<>
<Button onPress={close}>Cancel</Button>
<Button
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={!(ref.current?.checkVisibility() ?? false)}
isDisabled={
isDisabled || !(ref.current?.checkValidity() ?? true)
}
>
Confirm
</Button>
</>
)}
</div>
</Card>
</Form>

View File

@ -1,5 +1,4 @@
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
@ -8,6 +7,8 @@ type Properties = {
readonly disabled?: boolean;
};
// TODO: Use form action instead of JSON patching
// AND FIX JSON END OF UNEXPECTED INPUT
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher();
@ -17,26 +18,10 @@ export default function Modal({ isEnabled, disabled }: Properties) {
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
<Dialog.Panel
onSubmit={() => {
fetcher.submit(
{
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns.magic_dns': !isEnabled,
},
{
@ -44,15 +29,15 @@ export default function Modal({ isEnabled, disabled }: Properties) {
encType: 'application/json',
},
);
close();
}}
>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</Dialog.Gutter>
</>
)}
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
</Dialog.Panel>
</Dialog>
);

View File

@ -1,6 +1,6 @@
import { useFetcher } from 'react-router';
import { useState } from 'react';
import { Input } from 'react-aria-components';
import { useFetcher } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
@ -13,6 +13,7 @@ type Properties = {
readonly disabled?: boolean;
};
// TODO: Switch to form submit instead of JSON patch
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name);
const fetcher = useFetcher();
@ -45,28 +46,8 @@ export default function Modal({ name, disabled }: Properties) {
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of
unexpected behavior and may break existing devices in your
tailnet.
</Dialog.Text>
<TextField
label="Tailnet name"
placeholder="ts.net"
state={[newName, setNewName]}
className="my-2"
/>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={() => {
<Dialog.Panel
onSubmit={() => {
fetcher.submit(
{
'dns.base_domain': newName,
@ -76,15 +57,19 @@ export default function Modal({ name, disabled }: Properties) {
encType: 'application/json',
},
);
close();
}}
>
Rename
</Dialog.Action>
</Dialog.Gutter>
</>
)}
<Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<TextField
label="Tailnet name"
placeholder="ts.net"
state={[newName, setNewName]}
className="my-2"
/>
</Dialog.Panel>
</Dialog>
</div>

View File

@ -1,5 +1,5 @@
import { Form, useSubmit } from 'react-router';
import { useMemo, useState } from 'react';
import { useSubmit } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
@ -23,25 +23,17 @@ export default function AddDNS({ records }: Props) {
return lookup.value === ip;
}, [records, name, ip]);
// TODO: Ditch useSubmit here (non JSON form)
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
<Dialog.Panel
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName('');
setIp('');
submit(
{
'dns.extra_records': [
@ -58,11 +50,14 @@ export default function AddDNS({ records }: Props) {
encType: 'application/json',
},
);
close();
}}
>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<TextField
isRequired
label="Domain"
placeholder="test.example.com"
name="domain"
@ -70,6 +65,7 @@ export default function AddDNS({ records }: Props) {
className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
/>
<TextField
isRequired
label="IP Address"
placeholder="101.101.101.101"
name="ip"
@ -78,25 +74,10 @@ export default function AddDNS({ records }: Props) {
/>
{isDuplicate ? (
<p className="text-sm opacity-50">
A record with the domain name <Code>{name}</Code> and IP
address <Code>{ip}</Code> already exists.
A record with the domain name <Code>{name}</Code> and IP address{' '}
<Code>{ip}</Code> already exists.
</p>
) : undefined}
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,6 +1,6 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import { useSubmit } from 'react-router';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
@ -21,20 +21,10 @@ export default function AddNameserver({ nameservers }: Props) {
return (
<Dialog>
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add nameserver</Dialog.Title>
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<Form
method="POST"
<Dialog.Panel
onSubmit={(event) => {
event.preventDefault();
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
@ -75,9 +65,13 @@ export default function AddNameserver({ nameservers }: Props) {
setNs('');
setDomain('');
setSplit(false);
close();
}}
>
<Dialog.Title>Add nameserver</Dialog.Title>
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text>
<Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names.
</Dialog.Text>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
@ -103,9 +97,9 @@ export default function AddNameserver({ nameservers }: Props) {
Split DNS
</Tooltip.Button>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or
later for most platforms) will use this nameserver.
Older clients will ignore it.
Only clients that support split DNS (Tailscale v1.8 or later
for most platforms) will use this nameserver. Older clients
will ignore it.
</Tooltip.Body>
</Tooltip>
</div>
@ -123,9 +117,7 @@ export default function AddNameserver({ nameservers }: Props) {
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
@ -134,22 +126,11 @@ export default function AddNameserver({ nameservers }: Props) {
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this
suffix should use the nameserver.
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text>
</>
) : undefined}
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Add
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -49,7 +49,12 @@ export async function action({ request }: ActionFunctionArgs) {
return data({ success: false }, { status: 403 });
}
const patch = (await request.json()) as Record<string, unknown>;
const textData = await request.text();
if (!textData) {
return data({ success: true });
}
const patch = JSON.parse(textData) as Record<string, unknown>;
await patchConfig(patch);
if (context.integration?.onConfigChange) {

View File

@ -1,8 +1,8 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { send } from '~/utils/res';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));

View File

@ -1,6 +1,5 @@
import { KebabHorizontalIcon } from '@primer/octicons-react';
import { type ReactNode, useState } from 'react';
import React, { useState } from 'react';
import MenuComponent from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
@ -17,9 +16,11 @@ interface MenuProps {
routes: Route[];
users: User[];
magic?: string;
buttonChild?: ReactNode;
buttonChild?: React.ReactNode;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function Menu({
machine,
routes,
@ -27,12 +28,7 @@ export default function Menu({
users,
buttonChild,
}: MenuProps) {
const renameState = useState(false);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = useState(false);
const [modal, setModal] = useState<Modal>(null);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
@ -43,12 +39,63 @@ export default function Menu({
return (
<>
<Rename machine={machine} state={renameState} magic={magic} />
<Delete machine={machine} state={removeState} />
{expired ? undefined : <Expire machine={machine} state={expireState} />}
<Routes machine={machine} routes={routes} state={routesState} />
<Tags machine={machine} state={tagsState} />
<Move machine={machine} state={moveState} users={users} magic={magic} />
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'tags' && (
<Tags
machine={machine}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<MenuComponent>
{buttonChild ?? (
@ -63,26 +110,26 @@ export default function Menu({
</MenuComponent.Button>
)}
<MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}>
<MenuComponent.ItemButton onPress={() => setModal('rename')}>
Edit machine name
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}>
<MenuComponent.ItemButton onPress={() => setModal('routes')}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}>
<MenuComponent.ItemButton onPress={() => setModal('tags')}>
Edit ACL tags
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
<MenuComponent.ItemButton onPress={() => setModal('move')}>
Change owner
</MenuComponent.ItemButton>
{expired ? undefined : (
<MenuComponent.ItemButton control={expireState}>
<MenuComponent.ItemButton onPress={() => setModal('expire')}>
Expire
</MenuComponent.ItemButton>
)}
<MenuComponent.ItemButton
className="text-red-500 dark:text-red-400"
control={removeState}
onPress={() => setModal('remove')}
>
Remove
</MenuComponent.ItemButton>

View File

@ -1,57 +1,23 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit();
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To
re-add it, you will need to reauthenticate to your tailnet from
the device.
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
Remove
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,56 +1,23 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit();
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
Expire
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,44 +1,29 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
readonly machine: Machine;
readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
@ -46,26 +31,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Select.Item>
))}
</Select>
{magic ? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname{' '}
<Code className="text-sm">
{machine.givenName}.{magic}
</Code>
.
</p>
) : undefined}
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Change owner
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,16 +1,15 @@
import { Form, useFetcher, Link } from 'react-router';
import { Dispatch, SetStateAction, useState, useEffect } from 'react';
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react';
import { cn } from '~/utils/cn';
import { KeyIcon, ServerIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
import { Link, useFetcher } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Select from '~/components/Select';
import Menu from '~/components/Menu';
import Select from '~/components/Select';
import Spinner from '~/components/Spinner';
import TextField from '~/components/TextField';
import { toast } from '~/components/Toaster';
import { Machine, type User } from '~/types';
import type { User } from '~/types';
export interface NewProps {
server: string;
@ -19,7 +18,7 @@ export interface NewProps {
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>();
const mkeyState = useState(false);
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState('');
const [user, setUser] = useState('');
const [toasted, setToasted] = useState(false);
@ -36,14 +35,14 @@ export default function New(data: NewProps) {
}
setToasted(true);
}, [fetcher.data, toasted, mkey]);
}, [fetcher.data, toasted]);
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{(close) => (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
@ -53,13 +52,6 @@ export default function New(data: NewProps) {
</Code>{' '}
on your device.
</Dialog.Text>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
@ -81,33 +73,12 @@ export default function New(data: NewProps) {
</Select.Item>
))}
</Select>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
isDisabled={
!mkey || !mkey.trim().startsWith('mkey:') || !user
}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</Dialog.Gutter>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button variant="heavy">
Add Device
</Menu.Button>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<Menu.ItemButton onPress={() => setPushDialog(true)}>
<ServerIcon className="w-4 h-4 mr-2" />
Register Machine Key
</Menu.ItemButton>

View File

@ -1,47 +1,41 @@
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import type { Machine } from '~/types';
interface RenameProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
machine: Machine;
isOpen: boolean;
magic?: string;
setIsOpen: (isOpen: boolean) => void;
}
export default function Rename({ machine, state, magic }: RenameProps) {
export default function Rename({
machine,
magic,
isOpen,
setIsOpen,
}: RenameProps) {
const [name, setName] = useState(machine.givenName);
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit machine name for {machine.givenName}</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients, and
used when generating MagicDNS names.
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<TextField
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className="my-2"
defaultValue={machine.givenName}
onChange={setName}
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
@ -51,9 +45,8 @@ export default function Rename({ machine, state, magic }: RenameProps) {
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname{' '}
<Code className="text-sm">{machine.givenName}</Code> will no
longer point to this machine.
The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
will no longer point to this machine.
</p>
) : (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
@ -62,17 +55,6 @@ export default function Rename({ machine, state, magic }: RenameProps) {
</p>
)
) : undefined}
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,20 +1,25 @@
import { useMemo } from 'react';
import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import type { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
routes: Route[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
export default function Routes({
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
@ -40,17 +45,13 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
}, [exit]);
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by
advertising IP ranges as subnet routes.{' '}
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
@ -145,10 +146,7 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'exit-node');
form.set(
'routes',
exit.map((route) => route.id).join(','),
);
form.set('routes', exit.map((route) => route.id).join(','));
form.set('enabled', String(checked));
fetcher.submit(form, {
@ -159,17 +157,6 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
</div>
)}
</div>
<Dialog.Gutter>
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</Dialog.Gutter>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,32 +1,28 @@
import { PlusIcon, XIcon } from '@primer/octicons-react';
import { Form, useSubmit } from 'react-router';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useState } from 'react';
import { Button, Input } from 'react-aria-components';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
import cn from '~/utils/cn';
interface TagsProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Tags({ machine, state }: TagsProps) {
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the{' '}
ACL tags can be used to reference machines in your ACL policies. See
the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
@ -35,12 +31,6 @@ export default function Tags({ machine, state }: TagsProps) {
</Link>{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
@ -113,8 +103,7 @@ export default function Tags({ machine, state }: TagsProps) {
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') &&
'opacity-50 cursor-not-allowed',
!tag.startsWith('tag:') && 'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
@ -130,17 +119,6 @@ export default function Tags({ machine, state }: TagsProps) {
</Button>
</div>
</div>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Save
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,19 +1,19 @@
import { InfoIcon } from '@primer/octicons-react';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types';
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
import { ErrorPopup } from '~/components/Error'
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
import { menuAction } from './action';
import MachineRow from './components/machine';
@ -138,7 +138,5 @@ export default function Page() {
}
export function ErrorBoundary() {
return (
<ErrorPopup type="embedded" />
)
return <ErrorPopup type="embedded" />;
}

View File

@ -1,61 +1,22 @@
import { useFetcher } from 'react-router';
import type { PreAuthKey } from '~/types';
import { cn } from '~/utils/cn';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
import type { PreAuthKey } from '~/types';
interface Props {
authKey: PreAuthKey;
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button className="my-4">Expire Key</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Button>Expire Key</Dialog.Button>
<Dialog.Panel method="DELETE" variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<fetcher.Form
method="DELETE"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately prevent it
from being used to authenticate new devices. This action cannot
be undone.
Expiring this authentication key will immediately prevent it from
being used to authenticate new devices. This action cannot be undone.
</Dialog.Text>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</Dialog.Gutter>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,17 +1,10 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { useFetcher } from 'react-router';
import { useState } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import NumberField from '~/components/NumberField';
import Tooltip from '~/components/Tooltip';
import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput';
import Select from '~/components/Select';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import Spinner from '~/components/Spinner';
import { cn } from '~/utils/cn';
import type { User } from '~/types';
interface Props {
@ -31,20 +24,9 @@ export default function AddPreAuthKey(data: Props) {
<Dialog>
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Generate auth key</Dialog.Title>
<fetcher.Form
method="POST"
onSubmit={(e) => {
fetcher.submit(e.currentTarget);
close();
}}
>
<Dialog.Text className="font-semibold">User</Dialog.Text>
<Dialog.Text className="text-sm">
Attach this key to a user
</Dialog.Text>
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
<Select
label="Owner"
name="user"
@ -57,18 +39,14 @@ export default function AddPreAuthKey(data: Props) {
</Select.Item>
))}
</Select>
<Dialog.Text className="font-semibold mt-4">
Key Expiration
</Dialog.Text>
<Dialog.Text className="text-sm">
Set this key to expire after a certain number of days.
</Dialog.Text>
<NumberField
label="Expiry"
<NumberInput
isRequired
name="expiry"
label="Key Expiration"
description="Set this key to expire after a certain number of days."
minValue={1}
maxValue={365_000} // 1000 years
state={[expiry, setExpiry]}
defaultValue={90}
formatOptions={{
style: 'unit',
unit: 'day',
@ -91,17 +69,13 @@ export default function AddPreAuthKey(data: Props) {
}}
/>
</div>
<input
type="hidden"
name="reusable"
value={reusable.toString()}
/>
<input type="hidden" name="reusable" value={reusable.toString()} />
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically
removed once they go offline.{' '}
Devices authenticated with this key will be automatically removed
once they go offline.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
@ -119,29 +93,7 @@ export default function AddPreAuthKey(data: Props) {
}}
/>
</div>
<input
type="hidden"
name="ephemeral"
value={ephemeral.toString()}
/>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={!user || !expiry}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Generate
</Dialog.Action>
</Dialog.Gutter>
</fetcher.Form>
</>
)}
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
</Dialog.Panel>
</Dialog>
);

View File

@ -34,7 +34,7 @@ export default function Auth({ magic }: Props) {
You can add, remove, and rename users here.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
<Add />
</div>
</div>
</div>

View File

@ -41,7 +41,7 @@ export default function Oidc({ oidc, magic }: Props) {
manage users through your OIDC provider.
</p>
<div className="flex items-center gap-2 mt-4">
<Add magic={magic} />
<Add />
</div>
</div>
</div>

View File

@ -1,61 +1,24 @@
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
interface Props {
magic?: string;
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('');
const submit = useSubmit();
import Input from '~/components/Input';
export default function Add() {
return (
<Dialog>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a username to create a new user.{' '}
{magic ? (
<>
Since Magic DNS is enabled, machines will be accessible via{' '}
<Code>[machine]. .{magic}</Code>.
</>
) : undefined}
Enter a username to create a new user. Usernames can be addressed when
managing ACL policies.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
setUsername('');
}}
>
<input type="hidden" name="_method" value="create" />
<TextField
<Input
isRequired
name="username"
label="Username"
placeholder="my-new-user"
name="username"
state={[username, setUsername]}
className="my-2"
/>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Create
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
);

View File

@ -1,8 +1,4 @@
import { X } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import IconButton from '~/components/IconButton';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
@ -11,47 +7,20 @@ interface Props {
}
export default function Remove({ username }: Props) {
const submit = useSubmit();
const [dialog, setDialog] = useState(false);
return (
<>
<IconButton
label={`Delete ${username}`}
onPress={() => setDialog(true)}
>
<Dialog>
<Dialog.IconButton label={`Delete ${username}`}>
<X className="p-0.5" />
</IconButton>
<Dialog control={[dialog, setDialog]}>
<Dialog.Panel control={[dialog, setDialog]}>
{(close) => (
<>
</Dialog.IconButton>
<Dialog.Panel>
<Dialog.Title>Delete {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Are you sure you want to delete {username}? A deleted user
cannot be recovered.
Are you sure you want to delete {username}? A deleted user cannot be
recovered.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="username" value={username} />
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Delete
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
</>
);
}

View File

@ -1,65 +1,37 @@
import { Pencil } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react';
import IconButton from '~/components/IconButton';
import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField';
import Input from '~/components/Input';
interface Props {
username: string;
magic?: string;
}
export default function Rename({ username, magic }: Props) {
const submit = useSubmit();
const [dialog, setDialog] = useState(false);
// TODO: Server side validation before submitting
export default function Rename({ username }: Props) {
const [newName, setNewName] = useState(username);
return (
<>
<IconButton
label={`Rename ${username}`}
onPress={() => setDialog(true)}
>
<Dialog>
<Dialog.IconButton label={`Rename ${username}`}>
<Pencil className="p-1" />
</IconButton>
<Dialog control={[dialog, setDialog]}>
<Dialog.Panel control={[dialog, setDialog]}>
{(close) => (
<>
</Dialog.IconButton>
<Dialog.Panel>
<Dialog.Title>Rename {username}?</Dialog.Title>
<Dialog.Text className="mb-8">
Enter a new username for {username}
Enter a new username for {username}. Changing a username will not
update any ACL policies that may refer to this user by their old
username.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="old" value={username} />
<TextField
<Input
isRequired
name="new"
label="Username"
placeholder="my-new-name"
name="new"
state={[newName, setNewName]}
className="my-2"
/>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Rename
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
</>
);
}

View File

@ -1,28 +1,23 @@
import {
DataRef,
DndContext,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useActionData, useLoaderData, useSubmit } from 'react-router';
import { useEffect, useState } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle';
import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
import type { Machine, User } from '~/types';
import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale';
import { del, post, pull } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import { send } from '~/utils/res';
import Auth from './components/auth';
import Oidc from './components/oidc';
@ -305,7 +300,7 @@ function UserCard({ user, magic }: CardProps) {
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
<Rename username={user.name} />
{user.machines.length === 0 ? (
<Remove username={user.name} />
) : undefined}
@ -322,7 +317,5 @@ function UserCard({ user, magic }: CardProps) {
}
export function ErrorBoundary() {
return (
<ErrorPopup type="embedded" />
)
return <ErrorPopup type="embedded" />;
}