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

View File

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

View File

@ -1,6 +1,6 @@
import { useFetcher } from 'react-router';
import { useState } from 'react'; import { useState } from 'react';
import { Input } from 'react-aria-components'; import { Input } from 'react-aria-components';
import { useFetcher } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
@ -13,6 +13,7 @@ type Properties = {
readonly disabled?: boolean; readonly disabled?: boolean;
}; };
// TODO: Switch to form submit instead of JSON patch
export default function Modal({ name, disabled }: Properties) { export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name); const [newName, setNewName] = useState(name);
const fetcher = useFetcher(); const fetcher = useFetcher();
@ -45,28 +46,8 @@ export default function Modal({ name, disabled }: Properties) {
)} )}
Rename Tailnet Rename Tailnet
</Dialog.Button> </Dialog.Button>
<Dialog.Panel> <Dialog.Panel
{(close) => ( onSubmit={() => {
<>
<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={() => {
fetcher.submit( fetcher.submit(
{ {
'dns.base_domain': newName, 'dns.base_domain': newName,
@ -76,15 +57,19 @@ export default function Modal({ name, disabled }: Properties) {
encType: 'application/json', encType: 'application/json',
}, },
); );
close();
}} }}
> >
Rename <Dialog.Title>Rename Tailnet</Dialog.Title>
</Dialog.Action> <Dialog.Text>
</Dialog.Gutter> 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.Panel>
</Dialog> </Dialog>
</div> </div>

View File

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

View File

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

View File

@ -49,7 +49,12 @@ export async function action({ request }: ActionFunctionArgs) {
return data({ success: false }, { status: 403 }); 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); await patchConfig(patch);
if (context.integration?.onConfigChange) { if (context.integration?.onConfigChange) {

View File

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

View File

@ -1,6 +1,5 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'; import { KebabHorizontalIcon } from '@primer/octicons-react';
import { type ReactNode, useState } from 'react'; import React, { useState } from 'react';
import MenuComponent from '~/components/Menu'; import MenuComponent from '~/components/Menu';
import type { Machine, Route, User } from '~/types'; import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
@ -17,9 +16,11 @@ interface MenuProps {
routes: Route[]; routes: Route[];
users: User[]; users: User[];
magic?: string; magic?: string;
buttonChild?: ReactNode; buttonChild?: React.ReactNode;
} }
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function Menu({ export default function Menu({
machine, machine,
routes, routes,
@ -27,12 +28,7 @@ export default function Menu({
users, users,
buttonChild, buttonChild,
}: MenuProps) { }: MenuProps) {
const renameState = useState(false); const [modal, setModal] = useState<Modal>(null);
const expireState = useState(false);
const removeState = useState(false);
const routesState = useState(false);
const moveState = useState(false);
const tagsState = useState(false);
const expired = const expired =
machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01 00:00:00' ||
@ -43,12 +39,63 @@ export default function Menu({
return ( return (
<> <>
<Rename machine={machine} state={renameState} magic={magic} /> {modal === 'remove' && (
<Delete machine={machine} state={removeState} /> <Delete
{expired ? undefined : <Expire machine={machine} state={expireState} />} machine={machine}
<Routes machine={machine} routes={routes} state={routesState} /> isOpen={modal === 'remove'}
<Tags machine={machine} state={tagsState} /> setIsOpen={(isOpen) => {
<Move machine={machine} state={moveState} users={users} magic={magic} /> 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> <MenuComponent>
{buttonChild ?? ( {buttonChild ?? (
@ -63,26 +110,26 @@ export default function Menu({
</MenuComponent.Button> </MenuComponent.Button>
)} )}
<MenuComponent.Items> <MenuComponent.Items>
<MenuComponent.ItemButton control={renameState}> <MenuComponent.ItemButton onPress={() => setModal('rename')}>
Edit machine name Edit machine name
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
<MenuComponent.ItemButton control={routesState}> <MenuComponent.ItemButton onPress={() => setModal('routes')}>
Edit route settings Edit route settings
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
<MenuComponent.ItemButton control={tagsState}> <MenuComponent.ItemButton onPress={() => setModal('tags')}>
Edit ACL tags Edit ACL tags
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}> <MenuComponent.ItemButton onPress={() => setModal('move')}>
Change owner Change owner
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
{expired ? undefined : ( {expired ? undefined : (
<MenuComponent.ItemButton control={expireState}> <MenuComponent.ItemButton onPress={() => setModal('expire')}>
Expire Expire
</MenuComponent.ItemButton> </MenuComponent.ItemButton>
)} )}
<MenuComponent.ItemButton <MenuComponent.ItemButton
className="text-red-500 dark:text-red-400" className="text-red-500 dark:text-red-400"
control={removeState} onPress={() => setModal('remove')}
> >
Remove Remove
</MenuComponent.ItemButton> </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 Dialog from '~/components/Dialog';
import type { Machine } from '~/types'; import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps { interface DeleteProps {
readonly machine: Machine; machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
} }
export default function Delete({ machine, state }: DeleteProps) { export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
const submit = useSubmit();
return ( return (
<Dialog> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel control={state}> <Dialog.Panel variant="destructive">
{(close) => (
<>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title> <Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from your network. To This machine will be permanently removed from your network. To re-add
re-add it, you will need to reauthenticate to your tailnet from it, you will need to reauthenticate to your tailnet from the device.
the device.
</Dialog.Text> </Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="delete" /> <input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} /> <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.Panel>
</Dialog> </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 Dialog from '~/components/Dialog';
import type { Machine } from '~/types'; import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps { interface ExpireProps {
readonly machine: Machine; machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
} }
export default function Expire({ machine, state }: ExpireProps) { export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
const submit = useSubmit();
return ( return (
<Dialog> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel control={state}> <Dialog.Panel variant="destructive">
{(close) => (
<>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title> <Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This will disconnect the machine from your Tailnet. In order to This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device. reconnect, you will need to re-authenticate from the device.
</Dialog.Text> </Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" /> <input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} /> <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.Panel>
</Dialog> </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 Dialog from '~/components/Dialog';
import Select from '~/components/Select'; import Select from '~/components/Select';
import type { Machine, User } from '~/types'; import type { Machine, User } from '~/types';
interface MoveProps { interface MoveProps {
readonly machine: Machine; machine: Machine;
readonly users: User[]; users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]; isOpen: boolean;
readonly magic?: string; setIsOpen: (isOpen: boolean) => void;
} }
export default function Move({ machine, state, magic, users }: MoveProps) { export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
return ( return (
<Dialog> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel control={state}> <Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title> <Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
</Dialog.Text> </Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" /> <input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} /> <input type="hidden" name="id" value={machine.id} />
<Select <Select
label="Owner" label="Owner"
name="to" name="to"
placeholder="Select a user" placeholder="Select a user"
state={[owner, setOwner]} defaultSelectedKey={machine.user.id}
> >
{users.map((user) => ( {users.map((user) => (
<Select.Item key={user.id} id={user.name}> <Select.Item key={user.id} id={user.name}>
@ -46,26 +31,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Select.Item> </Select.Item>
))} ))}
</Select> </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.Panel>
</Dialog> </Dialog>
); );

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,19 @@
import { InfoIcon } from '@primer/octicons-react'; import { InfoIcon } from '@primer/octicons-react';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router'; import { useLoaderData } from 'react-router';
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components';
import Code from '~/components/Code'; import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link'; import Link from '~/components/Link';
import type { Machine, Route, User } from '~/types';
import { cn } from '~/utils/cn'; import { cn } from '~/utils/cn';
import { loadContext } from '~/utils/config/headplane'; import { loadContext } from '~/utils/config/headplane';
import { loadConfig } from '~/utils/config/headscale'; import { loadConfig } from '~/utils/config/headscale';
import { pull } from '~/utils/headscale'; import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server'; import { getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData'; import { useLiveData } from '~/utils/useLiveData';
import type { Machine, Route, User } from '~/types'; import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
import { queryAgent, initAgentSocket } from '~/utils/ws-agent';
import { ErrorPopup } from '~/components/Error'
import { menuAction } from './action'; import { menuAction } from './action';
import MachineRow from './components/machine'; import MachineRow from './components/machine';
@ -138,7 +138,5 @@ export default function Page() {
} }
export function ErrorBoundary() { export function ErrorBoundary() {
return ( return <ErrorPopup type="embedded" />;
<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 Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner'; import type { PreAuthKey } from '~/types';
interface Props { interface Props {
authKey: PreAuthKey; authKey: PreAuthKey;
} }
export default function ExpireKey({ authKey }: Props) { export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher();
return ( return (
<Dialog> <Dialog>
<Dialog.Button className="my-4">Expire Key</Dialog.Button> <Dialog.Button>Expire Key</Dialog.Button>
<Dialog.Panel> <Dialog.Panel method="DELETE" variant="destructive">
{(close) => (
<>
<Dialog.Title>Expire auth key?</Dialog.Title> <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="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} /> <input type="hidden" name="key" value={authKey.key} />
<Dialog.Text> <Dialog.Text>
Expiring this authentication key will immediately prevent it Expiring this authentication key will immediately prevent it from
from being used to authenticate new devices. This action cannot being used to authenticate new devices. This action cannot be undone.
be undone.
</Dialog.Text> </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.Panel>
</Dialog> </Dialog>
); );

View File

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

View File

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

View File

@ -41,7 +41,7 @@ export default function Oidc({ oidc, magic }: Props) {
manage users through your OIDC provider. manage users through your OIDC provider.
</p> </p>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<Add magic={magic} /> <Add />
</div> </div>
</div> </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 Code from '~/components/Code';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField'; import Input from '~/components/Input';
interface Props {
magic?: string;
}
export default function Add({ magic }: Props) {
const [username, setUsername] = useState('');
const submit = useSubmit();
export default function Add() {
return ( return (
<Dialog> <Dialog>
<Dialog.Button>Add a new user</Dialog.Button> <Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
{(close) => (
<>
<Dialog.Title>Add a new user</Dialog.Title> <Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-8"> <Dialog.Text className="mb-8">
Enter a username to create a new user.{' '} Enter a username to create a new user. Usernames can be addressed when
{magic ? ( managing ACL policies.
<>
Since Magic DNS is enabled, machines will be accessible via{' '}
<Code>[machine]. .{magic}</Code>.
</>
) : undefined}
</Dialog.Text> </Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
setUsername('');
}}
>
<input type="hidden" name="_method" value="create" /> <input type="hidden" name="_method" value="create" />
<TextField <Input
isRequired
name="username"
label="Username" label="Username"
placeholder="my-new-user" 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.Panel>
</Dialog> </Dialog>
); );

View File

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

View File

@ -1,65 +1,37 @@
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import { useState } from 'react'; import { useState } from 'react';
import IconButton from '~/components/IconButton';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import TextField from '~/components/TextField'; import Input from '~/components/Input';
interface Props { interface Props {
username: string; username: string;
magic?: string;
} }
export default function Rename({ username, magic }: Props) { // TODO: Server side validation before submitting
const submit = useSubmit(); export default function Rename({ username }: Props) {
const [dialog, setDialog] = useState(false);
const [newName, setNewName] = useState(username); const [newName, setNewName] = useState(username);
return ( return (
<> <Dialog>
<IconButton <Dialog.IconButton label={`Rename ${username}`}>
label={`Rename ${username}`}
onPress={() => setDialog(true)}
>
<Pencil className="p-1" /> <Pencil className="p-1" />
</IconButton> </Dialog.IconButton>
<Dialog control={[dialog, setDialog]}> <Dialog.Panel>
<Dialog.Panel control={[dialog, setDialog]}>
{(close) => (
<>
<Dialog.Title>Rename {username}?</Dialog.Title> <Dialog.Title>Rename {username}?</Dialog.Title>
<Dialog.Text className="mb-8"> <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> </Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
}}
>
<input type="hidden" name="_method" value="rename" /> <input type="hidden" name="_method" value="rename" />
<input type="hidden" name="old" value={username} /> <input type="hidden" name="old" value={username} />
<TextField <Input
isRequired
name="new"
label="Username" label="Username"
placeholder="my-new-name" 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.Panel>
</Dialog> </Dialog>
</>
); );
} }

View File

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