From 741f9aa6b5211f3b274e1a5d925bab1bb45673ee Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sun, 26 Jan 2025 15:04:13 -0500 Subject: [PATCH] feat: switch to new dialog across all code --- app/components/Dialog.tsx | 81 +++++--- app/routes/dns/components/magic.tsx | 59 +++--- app/routes/dns/components/rename.tsx | 67 +++---- app/routes/dns/dialogs/dns.tsx | 123 +++++------- app/routes/dns/dialogs/nameserver.tsx | 235 +++++++++++------------ app/routes/dns/overview.tsx | 7 +- app/routes/machines/action.tsx | 4 +- app/routes/machines/components/menu.tsx | 89 ++++++--- app/routes/machines/dialogs/delete.tsx | 60 ++---- app/routes/machines/dialogs/expire.tsx | 59 ++---- app/routes/machines/dialogs/move.tsx | 85 +++------ app/routes/machines/dialogs/new.tsx | 117 +++++------- app/routes/machines/dialogs/rename.tsx | 110 +++++------ app/routes/machines/dialogs/routes.tsx | 241 +++++++++++------------- app/routes/machines/dialogs/tags.tsx | 210 +++++++++------------ app/routes/machines/overview.tsx | 12 +- app/routes/settings/dialogs/expire.tsx | 59 +----- app/routes/settings/dialogs/new.tsx | 192 +++++++------------ app/routes/users/components/auth.tsx | 2 +- app/routes/users/components/oidc.tsx | 2 +- app/routes/users/dialogs/add.tsx | 65 ++----- app/routes/users/dialogs/remove.tsx | 57 ++---- app/routes/users/dialogs/rename.tsx | 74 +++----- app/routes/users/overview.tsx | 19 +- 24 files changed, 833 insertions(+), 1196 deletions(-) diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index 7f8f2f3..f11dcd6 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -21,10 +21,12 @@ import Title from '~/components/Title'; import { cn } from '~/utils/cn'; export interface DialogProps extends OverlayTriggerProps { - children: [ - React.ReactElement | React.ReactElement, - React.ReactElement, - ]; + children: + | [ + React.ReactElement | React.ReactElement, + React.ReactElement, + ] + | React.ReactElement; } function Dialog(props: DialogProps) { @@ -36,34 +38,53 @@ function Dialog(props: DialogProps) { state, ); - const [button, panel] = props.children; + if (Array.isArray(props.children)) { + const [button, panel] = props.children; + return ( + <> + {cloneElement(button, triggerProps)} + {state.isOpen && ( + + {cloneElement(panel, { + ...overlayProps, + close: () => state.close(), + })} + + )} + + ); + } + return ( - <> - {cloneElement(button, triggerProps)} - {state.isOpen && ( - - {cloneElement(panel, { - ...overlayProps, - close: () => state.close(), - })} - - )} - + + {cloneElement(props.children, { + ...overlayProps, + close: () => state.close(), + })} + ); } export interface DialogPanelProps extends AriaDialogProps { children: React.ReactNode; - variant?: 'normal' | 'destructive'; + variant?: 'normal' | 'destructive' | 'unactionable'; onSubmit?: React.FormEventHandler; method?: HTMLFormMethod; + isDisabled?: boolean; // Anonymous (passed by parent) close?: () => void; } function Panel(props: DialogPanelProps) { - const { children, onSubmit, close, variant, method = 'POST' } = props; + const { + children, + onSubmit, + isDisabled, + close, + variant, + method = 'POST', + } = props; const ref = useRef(null); const { dialogProps } = useDialog( { @@ -93,14 +114,22 @@ function Panel(props: DialogPanelProps) { {children}
- - + {variant === 'unactionable' ? ( + + ) : ( + <> + + + + )}
diff --git a/app/routes/dns/components/magic.tsx b/app/routes/dns/components/magic.tsx index d97bc10..996a85d 100644 --- a/app/routes/dns/components/magic.tsx +++ b/app/routes/dns/components/magic.tsx @@ -1,5 +1,4 @@ import { useFetcher } from 'react-router'; - import Dialog from '~/components/Dialog'; import Spinner from '~/components/Spinner'; @@ -8,6 +7,8 @@ type Properties = { readonly disabled?: boolean; }; +// TODO: Use form action instead of JSON patching +// AND FIX JSON END OF UNEXPECTED INPUT export default function Modal({ isEnabled, disabled }: Properties) { const fetcher = useFetcher(); @@ -17,42 +18,26 @@ export default function Modal({ isEnabled, disabled }: Properties) { {fetcher.state === 'idle' ? undefined : } {isEnabled ? 'Disable' : 'Enable'} Magic DNS - - {(close) => ( - <> - - {isEnabled ? 'Disable' : 'Enable'} Magic DNS - - - Devices will no longer be accessible via your tailnet domain. The - search domain will also be disabled. - - - - Cancel - - { - fetcher.submit( - { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'dns.magic_dns': !isEnabled, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - - close(); - }} - > - {isEnabled ? 'Disable' : 'Enable'} Magic DNS - - - - )} + { + fetcher.submit( + { + 'dns.magic_dns': !isEnabled, + }, + { + method: 'PATCH', + encType: 'application/json', + }, + ); + }} + > + + {isEnabled ? 'Disable' : 'Enable'} Magic DNS + + + Devices will no longer be accessible via your tailnet domain. The + search domain will also be disabled. + ); diff --git a/app/routes/dns/components/rename.tsx b/app/routes/dns/components/rename.tsx index 29c0ca2..d1544af 100644 --- a/app/routes/dns/components/rename.tsx +++ b/app/routes/dns/components/rename.tsx @@ -1,6 +1,6 @@ -import { useFetcher } from 'react-router'; import { useState } from 'react'; import { Input } from 'react-aria-components'; +import { useFetcher } from 'react-router'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; @@ -13,6 +13,7 @@ type Properties = { readonly disabled?: boolean; }; +// TODO: Switch to form submit instead of JSON patch export default function Modal({ name, disabled }: Properties) { const [newName, setNewName] = useState(name); const fetcher = useFetcher(); @@ -45,46 +46,30 @@ export default function Modal({ name, disabled }: Properties) { )} Rename Tailnet - - {(close) => ( - <> - Rename Tailnet - - Keep in mind that changing this can lead to all sorts of - unexpected behavior and may break existing devices in your - tailnet. - - - - - Cancel - - { - fetcher.submit( - { - 'dns.base_domain': newName, - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - - close(); - }} - > - Rename - - - - )} + { + fetcher.submit( + { + 'dns.base_domain': newName, + }, + { + method: 'PATCH', + encType: 'application/json', + }, + ); + }} + > + Rename Tailnet + + Keep in mind that changing this can lead to all sorts of unexpected + behavior and may break existing devices in your tailnet. + + diff --git a/app/routes/dns/dialogs/dns.tsx b/app/routes/dns/dialogs/dns.tsx index 25ae8d8..099dbff 100644 --- a/app/routes/dns/dialogs/dns.tsx +++ b/app/routes/dns/dialogs/dns.tsx @@ -1,5 +1,5 @@ -import { Form, useSubmit } from 'react-router'; import { useMemo, useState } from 'react'; +import { useSubmit } from 'react-router'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; @@ -23,80 +23,61 @@ export default function AddDNS({ records }: Props) { return lookup.value === ip; }, [records, name, ip]); + // TODO: Ditch useSubmit here (non JSON form) return ( Add DNS record - - {(close) => ( - <> - Add DNS record - - Enter the domain and IP address for the new DNS record. - -
{ - event.preventDefault(); - if (!name || !ip) return; + { + event.preventDefault(); + if (!name || !ip) return; - setName(''); - setIp(''); - - submit( - { - 'dns.extra_records': [ - ...records, - { - name, - type: 'A', - value: ip, - }, - ], - }, - { - method: 'PATCH', - encType: 'application/json', - }, - ); - - close(); - }} - > - - - {isDuplicate ? ( -

- A record with the domain name {name} and IP - address {ip} already exists. -

- ) : undefined} - - - Cancel - - - Add - - - - - )} + setName(''); + setIp(''); + submit( + { + 'dns.extra_records': [ + ...records, + { + name, + type: 'A', + value: ip, + }, + ], + }, + { + method: 'PATCH', + encType: 'application/json', + }, + ); + }} + > + Add DNS record + + Enter the domain and IP address for the new DNS record. + + + + {isDuplicate ? ( +

+ A record with the domain name {name} and IP address{' '} + {ip} already exists. +

+ ) : undefined}
); diff --git a/app/routes/dns/dialogs/nameserver.tsx b/app/routes/dns/dialogs/nameserver.tsx index 2b5f98d..c021eda 100644 --- a/app/routes/dns/dialogs/nameserver.tsx +++ b/app/routes/dns/dialogs/nameserver.tsx @@ -1,6 +1,6 @@ import { RepoForkedIcon } from '@primer/octicons-react'; -import { Form, useSubmit } from 'react-router'; import { useState } from 'react'; +import { useSubmit } from 'react-router'; import Dialog from '~/components/Dialog'; import Switch from '~/components/Switch'; @@ -21,135 +21,116 @@ export default function AddNameserver({ nameservers }: Props) { return ( Add nameserver - - {(close) => ( - <> - Add nameserver - Nameserver + { + event.preventDefault(); + if (!ns) return; + if (split) { + const splitNs: Record = {}; + 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); + }} + > + Add nameserver + Nameserver + + Use this IPv4 or IPv6 address to resolve names. + + +
+
+
+ + Restrict to domain + + + + + Split DNS + + + Only clients that support split DNS (Tailscale v1.8 or later + for most platforms) will use this nameserver. Older clients + will ignore it. + + +
- Use this IPv4 or IPv6 address to resolve names. + This nameserver will only be used for some domains. + +
+ { + setSplit(!split); + }} + /> +
+ {split ? ( + <> + Domain + + + Only single-label or fully-qualified queries matching this suffix + should use the nameserver. -
{ - event.preventDefault(); - if (!ns) return; - - if (split) { - const splitNs: Record = {}; - 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(); - }} - > - -
-
-
- - Restrict to domain - - - - - Split DNS - - - Only clients that support split DNS (Tailscale v1.8 or - later for most platforms) will use this nameserver. - Older clients will ignore it. - - -
- - This nameserver will only be used for some domains. - -
- { - setSplit(!split); - }} - /> -
- {split ? ( - <> - - Domain - - - - Only single-label or fully-qualified queries matching this - suffix should use the nameserver. - - - ) : undefined} - - - Cancel - - - Add - - - - )} + ) : undefined}
); diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx index 7a502fb..97f8292 100644 --- a/app/routes/dns/overview.tsx +++ b/app/routes/dns/overview.tsx @@ -49,7 +49,12 @@ export async function action({ request }: ActionFunctionArgs) { return data({ success: false }, { status: 403 }); } - const patch = (await request.json()) as Record; + const textData = await request.text(); + if (!textData) { + return data({ success: true }); + } + + const patch = JSON.parse(textData) as Record; await patchConfig(patch); if (context.integration?.onConfigChange) { diff --git a/app/routes/machines/action.tsx b/app/routes/machines/action.tsx index dea6745..21012b9 100644 --- a/app/routes/machines/action.tsx +++ b/app/routes/machines/action.tsx @@ -1,8 +1,8 @@ import type { ActionFunctionArgs } from 'react-router'; import { del, post } from '~/utils/headscale'; -import { getSession } from '~/utils/sessions.server'; -import { send } from '~/utils/res'; import log from '~/utils/log'; +import { send } from '~/utils/res'; +import { getSession } from '~/utils/sessions.server'; export async function menuAction(request: ActionFunctionArgs['request']) { const session = await getSession(request.headers.get('Cookie')); diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index 2edbb81..15769b7 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -1,6 +1,5 @@ import { KebabHorizontalIcon } from '@primer/octicons-react'; -import { type ReactNode, useState } from 'react'; - +import React, { useState } from 'react'; import MenuComponent from '~/components/Menu'; import type { Machine, Route, User } from '~/types'; import { cn } from '~/utils/cn'; @@ -17,9 +16,11 @@ interface MenuProps { routes: Route[]; users: User[]; magic?: string; - buttonChild?: ReactNode; + buttonChild?: React.ReactNode; } +type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; + export default function Menu({ machine, routes, @@ -27,12 +28,7 @@ export default function Menu({ users, buttonChild, }: MenuProps) { - const renameState = useState(false); - const expireState = useState(false); - const removeState = useState(false); - const routesState = useState(false); - const moveState = useState(false); - const tagsState = useState(false); + const [modal, setModal] = useState(null); const expired = machine.expiry === '0001-01-01 00:00:00' || @@ -43,12 +39,63 @@ export default function Menu({ return ( <> - - - {expired ? undefined : } - - - + {modal === 'remove' && ( + { + if (!isOpen) setModal(null); + }} + /> + )} + {modal === 'move' && ( + { + if (!isOpen) setModal(null); + }} + /> + )} + {modal === 'rename' && ( + { + if (!isOpen) setModal(null); + }} + /> + )} + {modal === 'routes' && ( + { + if (!isOpen) setModal(null); + }} + /> + )} + {modal === 'tags' && ( + { + if (!isOpen) setModal(null); + }} + /> + )} + {expired && modal === 'expire' ? undefined : ( + { + if (!isOpen) setModal(null); + }} + /> + )} {buttonChild ?? ( @@ -63,26 +110,26 @@ export default function Menu({ )} - + setModal('rename')}> Edit machine name - + setModal('routes')}> Edit route settings - + setModal('tags')}> Edit ACL tags - + setModal('move')}> Change owner {expired ? undefined : ( - + setModal('expire')}> Expire )} setModal('remove')} > Remove diff --git a/app/routes/machines/dialogs/delete.tsx b/app/routes/machines/dialogs/delete.tsx index cb2006b..3200bd6 100644 --- a/app/routes/machines/dialogs/delete.tsx +++ b/app/routes/machines/dialogs/delete.tsx @@ -1,57 +1,23 @@ -import { Form, useSubmit } from 'react-router'; -import type { Dispatch, SetStateAction } from 'react'; - import Dialog from '~/components/Dialog'; import type { Machine } from '~/types'; -import { cn } from '~/utils/cn'; interface DeleteProps { - readonly machine: Machine; - readonly state: [boolean, Dispatch>]; + machine: Machine; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } -export default function Delete({ machine, state }: DeleteProps) { - const submit = useSubmit(); - +export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) { return ( - - - {(close) => ( - <> - Remove {machine.givenName} - - This machine will be permanently removed from your network. To - re-add it, you will need to reauthenticate to your tailnet from - the device. - -
{ - submit(e.currentTarget); - }} - > - - - - - Cancel - - - Remove - - -
- - )} + + + Remove {machine.givenName} + + This machine will be permanently removed from your network. To re-add + it, you will need to reauthenticate to your tailnet from the device. + + + ); diff --git a/app/routes/machines/dialogs/expire.tsx b/app/routes/machines/dialogs/expire.tsx index 516407d..d04e458 100644 --- a/app/routes/machines/dialogs/expire.tsx +++ b/app/routes/machines/dialogs/expire.tsx @@ -1,56 +1,23 @@ -import { Form, useSubmit } from 'react-router'; -import type { Dispatch, SetStateAction } from 'react'; - import Dialog from '~/components/Dialog'; import type { Machine } from '~/types'; -import { cn } from '~/utils/cn'; interface ExpireProps { - readonly machine: Machine; - readonly state: [boolean, Dispatch>]; + machine: Machine; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } -export default function Expire({ machine, state }: ExpireProps) { - const submit = useSubmit(); - +export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) { return ( - - - {(close) => ( - <> - Expire {machine.givenName} - - This will disconnect the machine from your Tailnet. In order to - reconnect, you will need to re-authenticate from the device. - -
{ - submit(e.currentTarget); - }} - > - - - - - Cancel - - - Expire - - -
- - )} + + + Expire {machine.givenName} + + This will disconnect the machine from your Tailnet. In order to + reconnect, you will need to re-authenticate from the device. + + + ); diff --git a/app/routes/machines/dialogs/move.tsx b/app/routes/machines/dialogs/move.tsx index 83e4f1f..6c28695 100644 --- a/app/routes/machines/dialogs/move.tsx +++ b/app/routes/machines/dialogs/move.tsx @@ -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 Select from '~/components/Select'; import type { Machine, User } from '~/types'; interface MoveProps { - readonly machine: Machine; - readonly users: User[]; - readonly state: [boolean, Dispatch>]; - readonly magic?: string; + machine: Machine; + users: User[]; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } -export default function Move({ machine, state, magic, users }: MoveProps) { - const [owner, setOwner] = useState(machine.user.name); - const submit = useSubmit(); - +export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) { return ( - - - {(close) => ( - <> - Change the owner of {machine.givenName} - - The owner of the machine is the user associated with it. - -
{ - submit(e.currentTarget); - }} - > - - - - {magic ? ( -

- This machine is accessible by the hostname{' '} - - {machine.givenName}.{magic} - - . -

- ) : undefined} - - - Cancel - - - Change owner - - -
- - )} + + + Change the owner of {machine.givenName} + + The owner of the machine is the user associated with it. + + + + ); diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx index c9a93a4..1ecd8fe 100644 --- a/app/routes/machines/dialogs/new.tsx +++ b/app/routes/machines/dialogs/new.tsx @@ -1,16 +1,15 @@ -import { Form, useFetcher, Link } from 'react-router'; -import { Dispatch, SetStateAction, useState, useEffect } from 'react'; -import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'; -import { cn } from '~/utils/cn'; +import { KeyIcon, ServerIcon } from '@primer/octicons-react'; +import { useEffect, useState } from 'react'; +import { Link, useFetcher } from 'react-router'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; -import TextField from '~/components/TextField'; -import Select from '~/components/Select'; import Menu from '~/components/Menu'; +import Select from '~/components/Select'; import Spinner from '~/components/Spinner'; +import TextField from '~/components/TextField'; import { toast } from '~/components/Toaster'; -import { Machine, type User } from '~/types'; +import type { User } from '~/types'; export interface NewProps { server: string; @@ -19,7 +18,7 @@ export interface NewProps { export default function New(data: NewProps) { const fetcher = useFetcher<{ success?: boolean }>(); - const mkeyState = useState(false); + const [pushDialog, setPushDialog] = useState(false); const [mkey, setMkey] = useState(''); const [user, setUser] = useState(''); const [toasted, setToasted] = useState(false); @@ -36,78 +35,50 @@ export default function New(data: NewProps) { } setToasted(true); - }, [fetcher.data, toasted, mkey]); + }, [fetcher.data, toasted]); return ( <> - - - {(close) => ( - <> - Register Machine Key - - The machine key is given when you run{' '} - - tailscale up --login-server= - {data.server} - {' '} - on your device. - - { - fetcher.submit(e.currentTarget); - close(); - }} - > - - - - - - - Cancel - - - {fetcher.state === 'idle' ? undefined : ( - - )} - Register - - - - - )} + + + Register Machine Key + + The machine key is given when you run{' '} + + tailscale up --login-server= + {data.server} + {' '} + on your device. + + + + + - - Add Device - + Add Device - + setPushDialog(true)}> Register Machine Key diff --git a/app/routes/machines/dialogs/rename.tsx b/app/routes/machines/dialogs/rename.tsx index f1544c8..e983180 100644 --- a/app/routes/machines/dialogs/rename.tsx +++ b/app/routes/machines/dialogs/rename.tsx @@ -1,78 +1,60 @@ -import { Form, useSubmit } from 'react-router'; -import { type Dispatch, type SetStateAction, useState } from 'react'; - +import { useState } from 'react'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; import TextField from '~/components/TextField'; import type { Machine } from '~/types'; interface RenameProps { - readonly machine: Machine; - readonly state: [boolean, Dispatch>]; - readonly magic?: string; + machine: Machine; + isOpen: boolean; + magic?: string; + setIsOpen: (isOpen: boolean) => void; } -export default function Rename({ machine, state, magic }: RenameProps) { +export default function Rename({ + machine, + magic, + isOpen, + setIsOpen, +}: RenameProps) { const [name, setName] = useState(machine.givenName); - const submit = useSubmit(); return ( - - - {(close) => ( - <> - - Edit machine name for {machine.givenName} - - - This name is shown in the admin panel, in Tailscale clients, and - used when generating MagicDNS names. - -
{ - submit(e.currentTarget); - }} - > - - - - {magic ? ( - name.length > 0 && name !== machine.givenName ? ( -

- This machine will be accessible by the hostname{' '} - - {name.toLowerCase().replaceAll(/\s+/g, '-')} - - {'. '} - The hostname{' '} - {machine.givenName} will no - longer point to this machine. -

- ) : ( -

- This machine is accessible by the hostname{' '} - {machine.givenName}. -

- ) - ) : undefined} - - - Cancel - - - Rename - - - - - )} + + + Edit machine name for {machine.givenName} + + This name is shown in the admin panel, in Tailscale clients, and used + when generating MagicDNS names. + + + + + {magic ? ( + name.length > 0 && name !== machine.givenName ? ( +

+ This machine will be accessible by the hostname{' '} + + {name.toLowerCase().replaceAll(/\s+/g, '-')} + + {'. '} + The hostname {machine.givenName}{' '} + will no longer point to this machine. +

+ ) : ( +

+ This machine is accessible by the hostname{' '} + {machine.givenName}. +

+ ) + ) : undefined}
); diff --git a/app/routes/machines/dialogs/routes.tsx b/app/routes/machines/dialogs/routes.tsx index da67edd..25d01ca 100644 --- a/app/routes/machines/dialogs/routes.tsx +++ b/app/routes/machines/dialogs/routes.tsx @@ -1,20 +1,25 @@ +import { useMemo } from 'react'; import { useFetcher } from 'react-router'; -import { Dispatch, SetStateAction, useMemo } from 'react'; - import Dialog from '~/components/Dialog'; -import Switch from '~/components/Switch'; import Link from '~/components/Link'; +import Switch from '~/components/Switch'; import type { Machine, Route } from '~/types'; import { cn } from '~/utils/cn'; interface RoutesProps { - readonly machine: Machine; - readonly routes: Route[]; - readonly state: [boolean, Dispatch>]; + machine: Machine; + routes: Route[]; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } // TODO: Support deleting routes -export default function Routes({ machine, routes, state }: RoutesProps) { +export default function Routes({ + machine, + routes, + isOpen, + setIsOpen, +}: RoutesProps) { const fetcher = useFetcher(); // This is much easier with Object.groupBy but it's too new for us @@ -40,136 +45,118 @@ export default function Routes({ machine, routes, state }: RoutesProps) { }, [exit]); return ( - - - {(close) => ( - <> - - Edit route settings of {machine.givenName} - - Subnet routes - - Connect to devices you can't install Tailscale on by - advertising IP ranges as subnet routes.{' '} - - Learn More - - + + + Edit route settings of {machine.givenName} + Subnet routes + + Connect to devices you can't install Tailscale on by advertising + IP ranges as subnet routes.{' '} + + Learn More + + +
+ {subnet.length === 0 ? (
- {subnet.length === 0 ? ( -
-

No routes are advertised on this machine.

-
- ) : undefined} - {subnet.map((route) => ( -
-

{route.prefix}

- { - 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', - }); - }} - /> -
- ))} +

No routes are advertised on this machine.

- Exit nodes - - Allow your network to route internet traffic through this machine.{' '} - - Learn More - - + ) : undefined} + {subnet.map((route) => ( +
+

{route.prefix}

+ { + 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', + }); + }} + /> +
+ ))} +
+ Exit nodes + + Allow your network to route internet traffic through this machine.{' '} + + Learn More + + +
+ {exit.length === 0 ? (
- {exit.length === 0 ? ( -
-

This machine is not an exit node.

-
- ) : ( -
-

Use as exit node

- { - 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', - }); - }} - /> -
- )} +

This machine is not an exit node.

- - - Close - - - - )} + ) : ( +
+

Use as exit node

+ { + 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', + }); + }} + /> +
+ )} +
); diff --git a/app/routes/machines/dialogs/tags.tsx b/app/routes/machines/dialogs/tags.tsx index 1792cb1..b11777e 100644 --- a/app/routes/machines/dialogs/tags.tsx +++ b/app/routes/machines/dialogs/tags.tsx @@ -1,146 +1,124 @@ import { PlusIcon, XIcon } from '@primer/octicons-react'; -import { Form, useSubmit } from 'react-router'; -import { type Dispatch, type SetStateAction, useState } from 'react'; +import { useState } from 'react'; import { Button, Input } from 'react-aria-components'; - import Dialog from '~/components/Dialog'; import Link from '~/components/Link'; import type { Machine } from '~/types'; -import { cn } from '~/utils/cn'; +import cn from '~/utils/cn'; interface TagsProps { - readonly machine: Machine; - readonly state: [boolean, Dispatch>]; + machine: Machine; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } -export default function Tags({ machine, state }: TagsProps) { +export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) { const [tags, setTags] = useState(machine.forcedTags); const [tag, setTag] = useState(''); - const submit = useSubmit(); return ( - - - {(close) => ( - <> - Edit ACL tags for {machine.givenName} - - ACL tags can be used to reference machines in your ACL policies. - See the{' '} - - Tailscale documentation - {' '} - for more information. - -
{ - submit(e.currentTarget); - }} - > - - - + + + Edit ACL tags for {machine.givenName} + + ACL tags can be used to reference machines in your ACL policies. See + the{' '} + + Tailscale documentation + {' '} + for more information. + + + + +
+
+ {tags.length === 0 ? (
-
- {tags.length === 0 ? ( -
-

No tags are set on this machine.

-
- ) : ( - tags.map((item) => ( -
- {item} - -
- )) - )} -
+

No tags are set on this machine.

+
+ ) : ( + tags.map((item) => (
0 && - !tag.startsWith('tag:') && - 'outline outline-red-500', + 'px-2.5 py-1.5 flex', + 'items-center justify-between', + 'font-mono text-sm', )} > - { - setTag(e.currentTarget.value); - }} - /> + {item}
-
- - - Cancel - - - Save - - - - - )} + )) + )} +
+
0 && + !tag.startsWith('tag:') && + 'outline outline-red-500', + )} + > + { + setTag(e.currentTarget.value); + }} + /> + +
+
); diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 7a1f693..ba1e3e0 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -1,19 +1,19 @@ import { InfoIcon } from '@primer/octicons-react'; +import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { useLoaderData } from 'react-router'; -import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'; import Code from '~/components/Code'; +import { ErrorPopup } from '~/components/Error'; import Link from '~/components/Link'; +import type { Machine, Route, User } from '~/types'; import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { pull } from '~/utils/headscale'; import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; -import type { Machine, Route, User } from '~/types'; -import { queryAgent, initAgentSocket } from '~/utils/ws-agent'; -import { ErrorPopup } from '~/components/Error' +import { initAgentSocket, queryAgent } from '~/utils/ws-agent'; import { menuAction } from './action'; import MachineRow from './components/machine'; @@ -138,7 +138,5 @@ export default function Page() { } export function ErrorBoundary() { - return ( - - ) + return ; } diff --git a/app/routes/settings/dialogs/expire.tsx b/app/routes/settings/dialogs/expire.tsx index 33400b8..bdf26f7 100644 --- a/app/routes/settings/dialogs/expire.tsx +++ b/app/routes/settings/dialogs/expire.tsx @@ -1,61 +1,22 @@ -import { useFetcher } from 'react-router'; -import type { PreAuthKey } from '~/types'; -import { cn } from '~/utils/cn'; - import Dialog from '~/components/Dialog'; -import Spinner from '~/components/Spinner'; +import type { PreAuthKey } from '~/types'; interface Props { authKey: PreAuthKey; } export default function ExpireKey({ authKey }: Props) { - const fetcher = useFetcher(); - return ( - Expire Key - - {(close) => ( - <> - Expire auth key? - { - fetcher.submit(e.currentTarget); - close(); - }} - > - - - - Expiring this authentication key will immediately prevent it - from being used to authenticate new devices. This action cannot - be undone. - - - - Cancel - - - {fetcher.state === 'idle' ? undefined : ( - - )} - Expire - - - - - )} + Expire Key + + Expire auth key? + + + + Expiring this authentication key will immediately prevent it from + being used to authenticate new devices. This action cannot be undone. + ); diff --git a/app/routes/settings/dialogs/new.tsx b/app/routes/settings/dialogs/new.tsx index 8000492..cfb6921 100644 --- a/app/routes/settings/dialogs/new.tsx +++ b/app/routes/settings/dialogs/new.tsx @@ -1,17 +1,10 @@ -import { RepoForkedIcon } from '@primer/octicons-react'; -import { useFetcher } from 'react-router'; import { useState } from 'react'; - +import { useFetcher } from 'react-router'; import Dialog from '~/components/Dialog'; -import TextField from '~/components/TextField'; -import NumberField from '~/components/NumberField'; -import Tooltip from '~/components/Tooltip'; +import Link from '~/components/Link'; +import NumberInput from '~/components/NumberInput'; import Select from '~/components/Select'; import Switch from '~/components/Switch'; -import Link from '~/components/Link'; -import Spinner from '~/components/Spinner'; - -import { cn } from '~/utils/cn'; import type { User } from '~/types'; interface Props { @@ -31,117 +24,76 @@ export default function AddPreAuthKey(data: Props) { Create pre-auth key - {(close) => ( - <> - Generate auth key - { - fetcher.submit(e.currentTarget); - close(); - }} - > - User - - Attach this key to a user - - + {data.users.map((user) => ( + + {user.name} + + ))} + + +
+
+ Reusable + + Use this key to authenticate more than one device. + +
+ { + setReusable(!reusable); + }} + /> +
+ +
+
+ Ephemeral + + Devices authenticated with this key will be automatically removed + once they go offline.{' '} + - {data.users.map((user) => ( - - {user.name} - - ))} - - - Key Expiration - - - Set this key to expire after a certain number of days. - - -
-
- Reusable - - Use this key to authenticate more than one device. - -
- { - setReusable(!reusable); - }} - /> -
- -
-
- Ephemeral - - Devices authenticated with this key will be automatically - removed once they go offline.{' '} - - Learn more - - -
- { - setEphemeral(!ephemeral); - }} - /> -
- - - - Cancel - - - {fetcher.state === 'idle' ? undefined : ( - - )} - Generate - - - - - )} + Learn more + +
+
+ { + setEphemeral(!ephemeral); + }} + /> +
+
); diff --git a/app/routes/users/components/auth.tsx b/app/routes/users/components/auth.tsx index 9d16400..a7c4c37 100644 --- a/app/routes/users/components/auth.tsx +++ b/app/routes/users/components/auth.tsx @@ -34,7 +34,7 @@ export default function Auth({ magic }: Props) { You can add, remove, and rename users here.

- +
diff --git a/app/routes/users/components/oidc.tsx b/app/routes/users/components/oidc.tsx index 509cc5a..4e5175d 100644 --- a/app/routes/users/components/oidc.tsx +++ b/app/routes/users/components/oidc.tsx @@ -41,7 +41,7 @@ export default function Oidc({ oidc, magic }: Props) { manage users through your OIDC provider.

- +
diff --git a/app/routes/users/dialogs/add.tsx b/app/routes/users/dialogs/add.tsx index f50cac0..fe6348c 100644 --- a/app/routes/users/dialogs/add.tsx +++ b/app/routes/users/dialogs/add.tsx @@ -1,61 +1,24 @@ -import { Form, useSubmit } from 'react-router'; -import { useState } from 'react'; - import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; -import TextField from '~/components/TextField'; - -interface Props { - magic?: string; -} - -export default function Add({ magic }: Props) { - const [username, setUsername] = useState(''); - const submit = useSubmit(); +import Input from '~/components/Input'; +export default function Add() { return ( Add a new user - - {(close) => ( - <> - Add a new user - - Enter a username to create a new user.{' '} - {magic ? ( - <> - Since Magic DNS is enabled, machines will be accessible via{' '} - [machine]. .{magic}. - - ) : undefined} - -
{ - submit(event.currentTarget); - setUsername(''); - }} - > - - - - - Cancel - - - Create - - - - - )} + Add a new user + + Enter a username to create a new user. Usernames can be addressed when + managing ACL policies. + + +
); diff --git a/app/routes/users/dialogs/remove.tsx b/app/routes/users/dialogs/remove.tsx index 0fb2292..742074d 100644 --- a/app/routes/users/dialogs/remove.tsx +++ b/app/routes/users/dialogs/remove.tsx @@ -1,8 +1,4 @@ import { X } from 'lucide-react'; -import { Form, useSubmit } from 'react-router'; -import { useState } from 'react'; - -import IconButton from '~/components/IconButton'; import Code from '~/components/Code'; import Dialog from '~/components/Dialog'; @@ -11,47 +7,20 @@ interface Props { } export default function Remove({ username }: Props) { - const submit = useSubmit(); - const [dialog, setDialog] = useState(false); - return ( - <> - setDialog(true)} - > + + - - - - {(close) => ( - <> - Delete {username}? - - Are you sure you want to delete {username}? A deleted user - cannot be recovered. - -
{ - submit(event.currentTarget); - }} - > - - -
- - Cancel - - - Delete - -
-
- - )} -
-
- + + + Delete {username}? + + Are you sure you want to delete {username}? A deleted user cannot be + recovered. + + + + +
); } diff --git a/app/routes/users/dialogs/rename.tsx b/app/routes/users/dialogs/rename.tsx index d50c813..48be347 100644 --- a/app/routes/users/dialogs/rename.tsx +++ b/app/routes/users/dialogs/rename.tsx @@ -1,65 +1,37 @@ import { Pencil } from 'lucide-react'; -import { Form, useSubmit } from 'react-router'; import { useState } from 'react'; - -import IconButton from '~/components/IconButton'; import Dialog from '~/components/Dialog'; -import TextField from '~/components/TextField'; +import Input from '~/components/Input'; interface Props { username: string; - magic?: string; } -export default function Rename({ username, magic }: Props) { - const submit = useSubmit(); - const [dialog, setDialog] = useState(false); +// TODO: Server side validation before submitting +export default function Rename({ username }: Props) { const [newName, setNewName] = useState(username); return ( - <> - setDialog(true)} - > + + - - - - {(close) => ( - <> - Rename {username}? - - Enter a new username for {username} - -
{ - submit(event.currentTarget); - }} - > - - - -
- - Cancel - - - Rename - -
- - - )} -
-
- + + + Rename {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. + + + + + +
); } diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index 670fc0d..280518b 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,28 +1,23 @@ -import { - DataRef, - DndContext, - useDraggable, - useDroppable, -} from '@dnd-kit/core'; +import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'; import { PersonIcon } from '@primer/octicons-react'; +import { useEffect, useState } from 'react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { useActionData, useLoaderData, useSubmit } from 'react-router'; -import { useEffect, useState } from 'react'; import { ClientOnly } from 'remix-utils/client-only'; import Attribute from '~/components/Attribute'; import Card from '~/components/Card'; -import StatusCircle from '~/components/StatusCircle'; import { ErrorPopup } from '~/components/Error'; +import StatusCircle from '~/components/StatusCircle'; import { toast } from '~/components/Toaster'; import type { Machine, User } from '~/types'; import { cn } from '~/utils/cn'; import { loadContext } from '~/utils/config/headplane'; import { loadConfig } from '~/utils/config/headscale'; import { del, post, pull } from '~/utils/headscale'; +import { send } from '~/utils/res'; import { getSession } from '~/utils/sessions.server'; import { useLiveData } from '~/utils/useLiveData'; -import { send } from '~/utils/res'; import Auth from './components/auth'; import Oidc from './components/oidc'; @@ -305,7 +300,7 @@ function UserCard({ user, magic }: CardProps) { {user.name}
- + {user.machines.length === 0 ? ( ) : undefined} @@ -322,7 +317,5 @@ function UserCard({ user, magic }: CardProps) { } export function ErrorBoundary() { - return ( - - ) + return ; }