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<DialogPanelProps>, React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
]; React.ReactElement<DialogPanelProps>,
]
| React.ReactElement<DialogPanelProps>;
} }
function Dialog(props: DialogProps) { function Dialog(props: DialogProps) {
@ -36,34 +38,53 @@ function Dialog(props: DialogProps) {
state, state,
); );
const [button, panel] = props.children; if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (
<>
{cloneElement(button, triggerProps)}
{state.isOpen && (
<DModal state={state}>
{cloneElement(panel, {
...overlayProps,
close: () => state.close(),
})}
</DModal>
)}
</>
);
}
return ( return (
<> <DModal state={state}>
{cloneElement(button, triggerProps)} {cloneElement(props.children, {
{state.isOpen && ( ...overlayProps,
<DModal state={state}> close: () => state.close(),
{cloneElement(panel, { })}
...overlayProps, </DModal>
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">
<Button onPress={close}>Cancel</Button> {variant === 'unactionable' ? (
<Button <Button onPress={close}>Close</Button>
type="submit" ) : (
variant={variant === 'destructive' ? 'danger' : 'heavy'} <>
isDisabled={!(ref.current?.checkVisibility() ?? false)} <Button onPress={close}>Cancel</Button>
> <Button
Confirm type="submit"
</Button> variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={
isDisabled || !(ref.current?.checkValidity() ?? true)
}
>
Confirm
</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,42 +18,26 @@ 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={() => {
<> fetcher.submit(
<Dialog.Title> {
{isEnabled ? 'Disable' : 'Enable'} Magic DNS 'dns.magic_dns': !isEnabled,
</Dialog.Title> },
<Dialog.Text> {
Devices will no longer be accessible via your tailnet domain. The method: 'PATCH',
search domain will also be disabled. encType: 'application/json',
</Dialog.Text> },
<Dialog.Gutter> );
<Dialog.Action variant="cancel" onPress={close}> }}
Cancel >
</Dialog.Action> <Dialog.Title>
<Dialog.Action {isEnabled ? 'Disable' : 'Enable'} Magic DNS
variant="confirm" </Dialog.Title>
onPress={() => { <Dialog.Text>
fetcher.submit( Devices will no longer be accessible via your tailnet domain. The
{ search domain will also be disabled.
// eslint-disable-next-line @typescript-eslint/naming-convention </Dialog.Text>
'dns.magic_dns': !isEnabled,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</Dialog.Gutter>
</>
)}
</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,46 +46,30 @@ export default function Modal({ name, disabled }: Properties) {
)} )}
Rename Tailnet Rename Tailnet
</Dialog.Button> </Dialog.Button>
<Dialog.Panel> <Dialog.Panel
{(close) => ( onSubmit={() => {
<> fetcher.submit(
<Dialog.Title>Rename Tailnet</Dialog.Title> {
<Dialog.Text> 'dns.base_domain': newName,
Keep in mind that changing this can lead to all sorts of },
unexpected behavior and may break existing devices in your {
tailnet. method: 'PATCH',
</Dialog.Text> encType: 'application/json',
<TextField },
label="Tailnet name" );
placeholder="ts.net" }}
state={[newName, setNewName]} >
className="my-2" <Dialog.Title>Rename Tailnet</Dialog.Title>
/> <Dialog.Text>
<Dialog.Gutter> Keep in mind that changing this can lead to all sorts of unexpected
<Dialog.Action variant="cancel" onPress={close}> behavior and may break existing devices in your tailnet.
Cancel </Dialog.Text>
</Dialog.Action> <TextField
<Dialog.Action label="Tailnet name"
variant="confirm" placeholder="ts.net"
onPress={() => { state={[newName, setNewName]}
fetcher.submit( className="my-2"
{ />
'dns.base_domain': newName,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
close();
}}
>
Rename
</Dialog.Action>
</Dialog.Gutter>
</>
)}
</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,80 +23,61 @@ 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) => ( onSubmit={(event) => {
<> event.preventDefault();
<Dialog.Title>Add DNS record</Dialog.Title> if (!name || !ip) return;
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName(''); setName('');
setIp(''); setIp('');
submit(
submit( {
{ 'dns.extra_records': [
'dns.extra_records': [ ...records,
...records, {
{ name,
name, type: 'A',
type: 'A', value: ip,
value: ip, },
}, ],
], },
}, {
{ method: 'PATCH',
method: 'PATCH', 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.
<TextField </Dialog.Text>
label="Domain" <TextField
placeholder="test.example.com" isRequired
name="domain" label="Domain"
state={[name, setName]} placeholder="test.example.com"
className={cn('mt-2', isDuplicate && 'outline outline-red-500')} name="domain"
/> state={[name, setName]}
<TextField className={cn('mt-2', isDuplicate && 'outline outline-red-500')}
label="IP Address" />
placeholder="101.101.101.101" <TextField
name="ip" isRequired
state={[ip, setIp]} label="IP Address"
className={cn(isDuplicate && 'outline outline-red-500')} placeholder="101.101.101.101"
/> name="ip"
{isDuplicate ? ( state={[ip, setIp]}
<p className="text-sm opacity-50"> className={cn(isDuplicate && 'outline outline-red-500')}
A record with the domain name <Code>{name}</Code> and IP />
address <Code>{ip}</Code> already exists. {isDuplicate ? (
</p> <p className="text-sm opacity-50">
) : undefined} A record with the domain name <Code>{name}</Code> and IP address{' '}
<Dialog.Gutter> <Code>{ip}</Code> already exists.
<Dialog.Action variant="cancel" onPress={close}> </p>
Cancel ) : undefined}
</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,135 +21,116 @@ 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) => ( onSubmit={(event) => {
<> event.preventDefault();
<Dialog.Title>Add nameserver</Dialog.Title> if (!ns) return;
<Dialog.Text className="font-semibold">Nameserver</Dialog.Text> if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns];
}
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global;
globalNs.push(ns);
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('');
setDomain('');
setSplit(false);
}}
>
<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"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
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.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Use this IPv4 or IPv6 address to resolve names. This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => {
setSplit(!split);
}}
/>
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
should use the nameserver.
</Dialog.Text> </Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault();
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns];
}
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global;
globalNs.push(ns);
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('');
setDomain('');
setSplit(false);
close();
}}
>
<TextField
label="DNS Server"
placeholder="1.2.3.4"
name="ns"
state={[ns, setNs]}
className="mt-2 mb-8"
/>
<div className="flex items-center justify-between">
<div className="block">
<div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold">
Restrict to domain
</Dialog.Text>
<Tooltip>
<Tooltip.Button
className={cn(
'text-xs rounded-md px-1.5 py-0.5',
'bg-ui-200 dark:bg-ui-800',
'text-ui-600 dark:text-ui-300',
)}
>
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
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.
</Tooltip.Body>
</Tooltip>
</div>
<Dialog.Text className="text-sm">
This nameserver will only be used for some domains.
</Dialog.Text>
</div>
<Switch
label="Split DNS"
defaultSelected={split}
onChange={() => {
setSplit(!split);
}}
/>
</div>
{split ? (
<>
<Dialog.Text className="font-semibold mt-8">
Domain
</Dialog.Text>
<TextField
label="Domain"
placeholder="example.com"
name="domain"
state={[domain, setDomain]}
className="my-2"
/>
<Dialog.Text className="text-sm">
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>
</> </>
)} ) : undefined}
</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.Text>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title> This machine will be permanently removed from your network. To re-add
<Dialog.Text> it, you will need to reauthenticate to your tailnet from the device.
This machine will be permanently removed from your network. To </Dialog.Text>
re-add it, you will need to reauthenticate to your tailnet from <input type="hidden" name="_method" value="delete" />
the device. <input type="hidden" name="id" value={machine.id} />
</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.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.Text>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title> This will disconnect the machine from your Tailnet. In order to
<Dialog.Text> reconnect, you will need to re-authenticate from the device.
This will disconnect the machine from your Tailnet. In order to </Dialog.Text>
reconnect, you will need to re-authenticate from the device. <input type="hidden" name="_method" value="expire" />
</Dialog.Text> <input type="hidden" name="id" value={machine.id} />
<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.Panel>
</Dialog> </Dialog>
); );

View File

@ -1,71 +1,36 @@
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.Text>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title> The owner of the machine is the user associated with it.
<Dialog.Text> </Dialog.Text>
The owner of the machine is the user associated with it. <input type="hidden" name="_method" value="move" />
</Dialog.Text> <input type="hidden" name="id" value={machine.id} />
<Form <Select
method="POST" label="Owner"
onSubmit={(e) => { name="to"
submit(e.currentTarget); placeholder="Select a user"
}} defaultSelectedKey={machine.user.id}
> >
<input type="hidden" name="_method" value="move" /> {users.map((user) => (
<input type="hidden" name="id" value={machine.id} /> <Select.Item key={user.id} id={user.name}>
<Select {user.name}
label="Owner" </Select.Item>
name="to" ))}
placeholder="Select a user" </Select>
state={[owner, setOwner]}
>
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</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.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,78 +35,50 @@ 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{' '}
<Code isCopyable> <Code isCopyable>
tailscale up --login-server= tailscale up --login-server=
{data.server} {data.server}
</Code>{' '} </Code>{' '}
on your device. on your device.
</Dialog.Text> </Dialog.Text>
<fetcher.Form <input type="hidden" name="_method" value="register" />
method="POST" <input type="hidden" name="id" value="_" />
onSubmit={(e) => { <TextField
fetcher.submit(e.currentTarget); label="Machine Key"
close(); placeholder="mkey:ff....."
}} name="mkey"
> state={[mkey, setMkey]}
<input type="hidden" name="_method" value="register" /> className="my-2 font-mono"
<input type="hidden" name="id" value="_" /> />
<TextField <Select
label="Machine Key" label="Owner"
placeholder="mkey:ff....." name="user"
name="mkey" placeholder="Select a user"
state={[mkey, setMkey]} state={[user, setUser]}
className="my-2 font-mono" >
/> {data.users.map((user) => (
<Select <Select.Item key={user.id} id={user.name}>
label="Owner" {user.name}
name="user" </Select.Item>
placeholder="Select a user" ))}
state={[user, setUser]} </Select>
>
{data.users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</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.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,78 +1,60 @@
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.Text>
<Dialog.Title> This name is shown in the admin panel, in Tailscale clients, and used
Edit machine name for {machine.givenName} when generating MagicDNS names.
</Dialog.Title> </Dialog.Text>
<Dialog.Text> <input type="hidden" name="_method" value="rename" />
This name is shown in the admin panel, in Tailscale clients, and <input type="hidden" name="id" value={machine.id} />
used when generating MagicDNS names. <TextField
</Dialog.Text> label="Machine name"
<Form placeholder="Machine name"
method="POST" name="name"
onSubmit={(e) => { className="my-2"
submit(e.currentTarget); defaultValue={machine.givenName}
}} onChange={setName}
> />
<input type="hidden" name="_method" value="rename" /> {magic ? (
<input type="hidden" name="id" value={machine.id} /> name.length > 0 && name !== machine.givenName ? (
<TextField <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
label="Machine name" This machine will be accessible by the hostname{' '}
placeholder="Machine name" <Code className="text-sm">
name="name" {name.toLowerCase().replaceAll(/\s+/g, '-')}
state={[name, setName]} </Code>
className="my-2" {'. '}
/> The hostname <Code className="text-sm">{machine.givenName}</Code>{' '}
{magic ? ( will no longer point to this machine.
name.length > 0 && name !== machine.givenName ? ( </p>
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight"> ) : (
This machine will be accessible by the hostname{' '} <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
<Code className="text-sm"> This machine is accessible by the hostname{' '}
{name.toLowerCase().replaceAll(/\s+/g, '-')} <Code className="text-sm">{machine.givenName}</Code>.
</Code> </p>
{'. '} )
The hostname{' '} ) : undefined}
<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">
This machine is accessible by the hostname{' '}
<Code className="text-sm">{machine.givenName}</Code>.
</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.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,136 +45,118 @@ 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.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Title> <Dialog.Text>
Edit route settings of {machine.givenName} Connect to devices you can&apos;t install Tailscale on by advertising
</Dialog.Title> IP ranges as subnet routes.{' '}
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text> <Link
<Dialog.Text> to="https://tailscale.com/kb/1019/subnets"
Connect to devices you can&apos;t install Tailscale on by name="Tailscale Subnets Documentation"
advertising IP ranges as subnet routes.{' '} >
<Link Learn More
to="https://tailscale.com/kb/1019/subnets" </Link>
name="Tailscale Subnets Documentation" </Dialog.Text>
> <div
Learn More className={cn(
</Link> 'rounded-lg overflow-y-auto my-2',
</Dialog.Text> 'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{subnet.length === 0 ? (
<div <div
className={cn( className={cn(
'rounded-lg overflow-y-auto my-2', 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', 'items-center justify-center',
'border border-zinc-200 dark:border-zinc-700', 'text-ui-600 dark:text-ui-300',
)} )}
> >
{subnet.length === 0 ? ( <p>No routes are advertised on this machine.</p>
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No routes are advertised on this machine.</p>
</div>
) : undefined}
{subnet.map((route) => (
<div
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
))}
</div> </div>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text> ) : undefined}
<Dialog.Text> {subnet.map((route) => (
Allow your network to route internet traffic through this machine.{' '} <div
<Link key={route.id}
to="https://tailscale.com/kb/1103/exit-nodes" className={cn(
name="Tailscale Exit-node Documentation" 'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
> 'items-center justify-between',
Learn More )}
</Link> >
</Dialog.Text> <p>{route.prefix}</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
))}
</div>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
>
Learn More
</Link>
</Dialog.Text>
<div
className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{exit.length === 0 ? (
<div <div
className={cn( className={cn(
'rounded-lg overflow-y-auto my-2', 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top', 'items-center justify-center',
'border border-zinc-200 dark:border-zinc-700', 'text-ui-600 dark:text-ui-300',
)} )}
> >
{exit.length === 0 ? ( <p>This machine is not an exit node.</p>
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>This machine is not an exit node.</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>Use as exit node</p>
<Switch
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
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('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
)}
</div> </div>
<Dialog.Gutter> ) : (
<Dialog.Action <div
variant="cancel" className={cn(
isDisabled={fetcher.state === 'submitting'} 'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
onPress={close} 'items-center justify-between',
> )}
Close >
</Dialog.Action> <p>Use as exit node</p>
</Dialog.Gutter> <Switch
</> defaultSelected={exitEnabled}
)} label="Enabled"
onChange={(checked) => {
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('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</div>
)}
</div>
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );

View File

@ -1,146 +1,124 @@
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.Text>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title> ACL tags can be used to reference machines in your ACL policies. See
<Dialog.Text> the{' '}
ACL tags can be used to reference machines in your ACL policies. <Link
See the{' '} to="https://tailscale.com/kb/1068/acl-tags"
<Link name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags" >
name="Tailscale documentation" Tailscale documentation
> </Link>{' '}
Tailscale documentation for more information.
</Link>{' '} </Dialog.Text>
for more information. <input type="hidden" name="_method" value="tags" />
</Dialog.Text> <input type="hidden" name="id" value={machine.id} />
<Form <input type="hidden" name="tags" value={tags.join(',')} />
method="POST" <div
onSubmit={(e) => { className={cn(
submit(e.currentTarget); 'border border-ui-300 rounded-lg overflow-visible',
}} 'dark:border-ui-700 dark:text-ui-300 mt-4',
> )}
<input type="hidden" name="_method" value="tags" /> >
<input type="hidden" name="id" value={machine.id} /> <div className="divide-y divide-ui-200 dark:divide-ui-600">
<input type="hidden" name="tags" value={tags.join(',')} /> {tags.length === 0 ? (
<div <div
className={cn( className={cn(
'border border-ui-300 rounded-lg overflow-visible', 'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'dark:border-ui-700 dark:text-ui-300 mt-4', 'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)} )}
> >
<div className="divide-y divide-ui-200 dark:divide-ui-600"> <p>No tags are set on this machine.</p>
{tags.length === 0 ? ( </div>
<div ) : (
className={cn( tags.map((item) => (
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>No tags are set on this machine.</p>
</div>
) : (
tags.map((item) => (
<div
key={item}
id={item}
className={cn(
'px-2.5 py-1.5 flex',
'items-center justify-between',
'font-mono text-sm',
)}
>
{item}
<Button
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
<div <div
key={item}
id={item}
className={cn( className={cn(
'flex px-2.5 py-1.5 w-full', 'px-2.5 py-1.5 flex',
'border-t border-ui-300 dark:border-ui-700', 'items-center justify-between',
'rounded-b-lg justify-between items-center', 'font-mono text-sm',
'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 &&
!tag.startsWith('tag:') &&
'outline outline-red-500',
)} )}
> >
<Input {item}
placeholder="tag:example"
className={cn(
'bg-transparent w-full',
'border-none focus:ring-0',
'focus:outline-none font-mono text-sm',
'dark:bg-transparent dark:text-ui-300',
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value);
}}
/>
<Button <Button
className={cn( className="rounded-full p-0 w-6 h-6"
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => { onPress={() => {
setTags([...tags, tag]); setTags(tags.filter((tag) => tag !== item));
setTag('');
}} }}
> >
<PlusIcon className="w-4 h-4" /> <XIcon className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> ))
<Dialog.Gutter> )}
<Dialog.Action variant="cancel" onPress={close}> </div>
Cancel <div
</Dialog.Action> className={cn(
<Dialog.Action variant="confirm" onPress={close}> 'flex px-2.5 py-1.5 w-full',
Save 'border-t border-ui-300 dark:border-ui-700',
</Dialog.Action> 'rounded-b-lg justify-between items-center',
</Dialog.Gutter> 'dark:bg-ui-800 dark:text-ui-300',
</Form> 'focus-within:ring-2 focus-within:ring-blue-600',
</> tag.length > 0 &&
)} !tag.startsWith('tag:') &&
'outline outline-red-500',
)}
>
<Input
placeholder="tag:example"
className={cn(
'bg-transparent w-full',
'border-none focus:ring-0',
'focus:outline-none font-mono text-sm',
'dark:bg-transparent dark:text-ui-300',
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value);
}}
/>
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:') && 'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</div>
</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>
<> <input type="hidden" name="user" value={authKey.user} />
<Dialog.Title>Expire auth key?</Dialog.Title> <input type="hidden" name="key" value={authKey.key} />
<fetcher.Form <Dialog.Text>
method="DELETE" Expiring this authentication key will immediately prevent it from
onSubmit={(e) => { being used to authenticate new devices. This action cannot be undone.
fetcher.submit(e.currentTarget); </Dialog.Text>
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.
</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,117 +24,76 @@ 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.Text className="font-semibold">User</Dialog.Text>
<Dialog.Title>Generate auth key</Dialog.Title> <Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
<fetcher.Form <Select
method="POST" label="Owner"
onSubmit={(e) => { name="user"
fetcher.submit(e.currentTarget); placeholder="Select a user"
close(); state={[user, setUser]}
}} >
> {data.users.map((user) => (
<Dialog.Text className="font-semibold">User</Dialog.Text> <Select.Item key={user.id} id={user.name}>
<Dialog.Text className="text-sm"> {user.name}
Attach this key to a user </Select.Item>
</Dialog.Text> ))}
<Select </Select>
label="Owner" <NumberInput
name="user" isRequired
placeholder="Select a user" name="expiry"
state={[user, setUser]} label="Key Expiration"
description="Set this key to expire after a certain number of days."
minValue={1}
maxValue={365_000} // 1000 years
defaultValue={90}
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<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.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
> >
{data.users.map((user) => ( Learn more
<Select.Item key={user.id} id={user.name}> </Link>
{user.name} </Dialog.Text>
</Select.Item> </div>
))} <Switch
</Select> label="Ephemeral"
<Dialog.Text className="font-semibold mt-4"> name="ephemeral"
Key Expiration defaultSelected={ephemeral}
</Dialog.Text> onChange={() => {
<Dialog.Text className="text-sm"> setEphemeral(!ephemeral);
Set this key to expire after a certain number of days. }}
</Dialog.Text> />
<NumberField </div>
label="Expiry" <input type="hidden" name="ephemeral" value={ephemeral.toString()} />
name="expiry"
minValue={1}
maxValue={365_000} // 1000 years
state={[expiry, setExpiry]}
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<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.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</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>
</>
)}
</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.Text className="mb-8">
<Dialog.Title>Add a new user</Dialog.Title> Enter a username to create a new user. Usernames can be addressed when
<Dialog.Text className="mb-8"> managing ACL policies.
Enter a username to create a new user.{' '} </Dialog.Text>
{magic ? ( <input type="hidden" name="_method" value="create" />
<> <Input
Since Magic DNS is enabled, machines will be accessible via{' '} isRequired
<Code>[machine]. .{magic}</Code>. name="username"
</> label="Username"
) : undefined} placeholder="my-new-user"
</Dialog.Text> />
<Form
method="POST"
onSubmit={(event) => {
submit(event.currentTarget);
setUsername('');
}}
>
<input type="hidden" name="_method" value="create" />
<TextField
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.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]}> <Dialog.Title>Delete {username}?</Dialog.Title>
{(close) => ( <Dialog.Text className="mb-8">
<> Are you sure you want to delete {username}? A deleted user cannot be
<Dialog.Title>Delete {username}?</Dialog.Title> recovered.
<Dialog.Text className="mb-8"> </Dialog.Text>
Are you sure you want to delete {username}? A deleted user <input type="hidden" name="_method" value="delete" />
cannot be recovered. <input type="hidden" name="username" value={username} />
</Dialog.Text> </Dialog.Panel>
<Form </Dialog>
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 { 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]}> <Dialog.Title>Rename {username}?</Dialog.Title>
{(close) => ( <Dialog.Text className="mb-8">
<> Enter a new username for {username}. Changing a username will not
<Dialog.Title>Rename {username}?</Dialog.Title> update any ACL policies that may refer to this user by their old
<Dialog.Text className="mb-8"> username.
Enter a new username for {username} </Dialog.Text>
</Dialog.Text> <input type="hidden" name="_method" value="rename" />
<Form <input type="hidden" name="old" value={username} />
method="POST" <Input
onSubmit={(event) => { isRequired
submit(event.currentTarget); name="new"
}} label="Username"
> placeholder="my-new-name"
<input type="hidden" name="_method" value="rename" /> />
<input type="hidden" name="old" value={username} /> </Dialog.Panel>
<TextField </Dialog>
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 { 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" />
)
} }