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

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

View file

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

View file

@ -1,57 +1,23 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface DeleteProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit();
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To
re-add it, you will need to reauthenticate to your tailnet from
the device.
</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 isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);

View file

@ -1,56 +1,23 @@
import { Form, useSubmit } from 'react-router';
import type { Dispatch, SetStateAction } from 'react';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
import { cn } from '~/utils/cn';
interface ExpireProps {
readonly machine: Machine;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit();
export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white',
)}
onPress={close}
>
Expire
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire {machine.givenName}</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</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 Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
readonly machine: Machine;
readonly users: User[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name);
const submit = useSubmit();
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{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 isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);

View file

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

View file

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

View file

@ -1,20 +1,25 @@
import { useMemo } from 'react';
import { useFetcher } from 'react-router';
import { Dispatch, SetStateAction, useMemo } from 'react';
import Dialog from '~/components/Dialog';
import Switch from '~/components/Switch';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import type { Machine, Route } from '~/types';
import { cn } from '~/utils/cn';
interface RoutesProps {
readonly machine: Machine;
readonly routes: Route[];
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
machine: Machine;
routes: Route[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, state }: RoutesProps) {
export default function Routes({
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
@ -40,136 +45,118 @@ export default function Routes({ machine, routes, state }: RoutesProps) {
}, [exit]);
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by
advertising IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets 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',
)}
>
{subnet.length === 0 ? (
<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',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
{subnet.length === 0 ? (
<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>
))}
<p>No routes are advertised on this machine.</p>
</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>
) : 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>
<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
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',
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
{exit.length === 0 ? (
<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>
)}
<p>This machine is not an exit node.</p>
</div>
<Dialog.Gutter>
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</Dialog.Gutter>
</>
)}
) : (
<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>
</Dialog.Panel>
</Dialog>
);

View file

@ -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<SetStateAction<boolean>>];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Tags({ machine, state }: TagsProps) {
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const submit = useSubmit();
return (
<Dialog>
<Dialog.Panel control={state}>
{(close) => (
<>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget);
}}
>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See
the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<div
className={cn(
'border border-ui-300 rounded-lg overflow-visible',
'dark:border-ui-700 dark:text-ui-300 mt-4',
)}
>
<div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 ? (
<div
className={cn(
'border border-ui-300 rounded-lg overflow-visible',
'dark:border-ui-700 dark:text-ui-300 mt-4',
'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',
)}
>
<div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0 ? (
<div
className={cn(
'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>
<p>No tags are set on this machine.</p>
</div>
) : (
tags.map((item) => (
<div
key={item}
id={item}
className={cn(
'flex px-2.5 py-1.5 w-full',
'border-t border-ui-300 dark:border-ui-700',
'rounded-b-lg justify-between items-center',
'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',
'px-2.5 py-1.5 flex',
'items-center justify-between',
'font-mono text-sm',
)}
>
<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);
}}
/>
{item}
<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)
}
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags([...tags, tag]);
setTag('');
setTags(tags.filter((tag) => tag !== item));
}}
>
<PlusIcon className="w-4 h-4" />
<XIcon className="w-4 h-4" />
</Button>
</div>
</div>
<Dialog.Gutter>
<Dialog.Action variant="cancel" onPress={close}>
Cancel
</Dialog.Action>
<Dialog.Action variant="confirm" onPress={close}>
Save
</Dialog.Action>
</Dialog.Gutter>
</Form>
</>
)}
))
)}
</div>
<div
className={cn(
'flex px-2.5 py-1.5 w-full',
'border-t border-ui-300 dark:border-ui-700',
'rounded-b-lg justify-between items-center',
'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
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>
);

View file

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