diff --git a/app/routes/_data.machines.$id.tsx b/app/routes/_data.machines.$id.tsx index 773844b..866f714 100644 --- a/app/routes/_data.machines.$id.tsx +++ b/app/routes/_data.machines.$id.tsx @@ -1,10 +1,11 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node' import { Link, useLoaderData } from '@remix-run/react' import Attribute from '~/components/Attribute' import Card from '~/components/Card' import StatusCircle from '~/components/StatusCircle' -import { type Machine, Route } from '~/types' +import { type Machine, Route, User } from '~/types' import { cn } from '~/utils/cn' import { loadContext } from '~/utils/config/headplane' import { loadConfig } from '~/utils/config/headscale' @@ -31,16 +32,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } } - const [machine, routes] = await Promise.all([ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [machine, routes, users] = await Promise.all([ pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!), - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), + pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), ]) return { machine: machine.node, routes: routes.routes.filter(route => route.node.id === params.id), + users: users.users, magic, } } @@ -50,7 +51,7 @@ export async function action({ request }: ActionFunctionArgs) { } export default function Page() { - const { machine, magic, routes } = useLoaderData() + const { machine, magic, routes, users } = useLoaderData() useLiveData({ interval: 1000 }) const expired = machine.expiry === '0001-01-01 00:00:00' @@ -92,6 +93,7 @@ export default function Page() { diff --git a/app/routes/_data.machines._index/action.tsx b/app/routes/_data.machines._index/action.tsx index b4dfdf1..31f35c0 100644 --- a/app/routes/_data.machines._index/action.tsx +++ b/app/routes/_data.machines._index/action.tsx @@ -61,6 +61,25 @@ export async function menuAction(request: ActionFunctionArgs['request']) { return json({ message: 'Route updated' }) } + case 'move': { + if (!data.has('to')) { + return json({ message: 'No destination provided' }, { + status: 400, + }) + } + + const to = String(data.get('to')) + + try { + await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) + return json({ message: `Moved node ${id} to ${to}` }) + } catch { + return json({ message: `Failed to move node ${id} to ${to}` }, { + status: 500, + }) + } + } + default: { return json({ message: 'Invalid method' }, { status: 400, diff --git a/app/routes/_data.machines._index/dialogs/move.tsx b/app/routes/_data.machines._index/dialogs/move.tsx new file mode 100644 index 0000000..67de9ce --- /dev/null +++ b/app/routes/_data.machines._index/dialogs/move.tsx @@ -0,0 +1,119 @@ +import { Form, useSubmit } from '@remix-run/react' +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 +} + +export default function Move({ machine, state, magic, users }: MoveProps) { + const [owner, setOwner] = useState(machine.user.name) + const submit = useSubmit() + + return ( + + + {close => ( + <> + + Change the owner of + {' '} + {machine.givenName} + + + The owner of the machine is the user associated with it. + When MagicDNS is enabled, the username of the owner + will control the hostname of the machine. + +
{ + submit(e.currentTarget) + }} + > + + + + {magic + ? ( + owner === machine.user.name + ? ( +

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

+ ) + : ( +

+ This machine will be accessible by the hostname + {' '} + + {machine.givenName} + . + {owner} + . + {magic} + + {'. '} + The hostname + {' '} + + {machine.givenName} + . + {machine.user.name} + . + {magic} + + {' '} + will no longer point to this machine. +

+ ) + ) + : undefined} +
+ + Cancel + + + Change owner + +
+
+ + )} +
+
+ ) +} diff --git a/app/routes/_data.machines._index/machine.tsx b/app/routes/_data.machines._index/machine.tsx index 27a3b36..d8ffdd2 100644 --- a/app/routes/_data.machines._index/machine.tsx +++ b/app/routes/_data.machines._index/machine.tsx @@ -4,7 +4,7 @@ import { Link } from '@remix-run/react' import Menu from '~/components/Menu' import StatusCircle from '~/components/StatusCircle' import { toast } from '~/components/Toaster' -import { type Machine, type Route } from '~/types' +import { type Machine, type Route, User } from '~/types' import { cn } from '~/utils/cn' import MenuOptions from './menu' @@ -12,10 +12,11 @@ import MenuOptions from './menu' interface Props { readonly machine: Machine readonly routes: Route[] + readonly users: User[] readonly magic?: string } -export default function MachineRow({ machine, routes, magic }: Props) { +export default function MachineRow({ machine, routes, magic, users }: Props) { const expired = machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01T00:00:00Z' ? false @@ -142,6 +143,7 @@ export default function MachineRow({ machine, routes, magic }: Props) { diff --git a/app/routes/_data.machines._index/menu.tsx b/app/routes/_data.machines._index/menu.tsx index 4076781..c6818f3 100644 --- a/app/routes/_data.machines._index/menu.tsx +++ b/app/routes/_data.machines._index/menu.tsx @@ -2,25 +2,28 @@ import { KebabHorizontalIcon } from '@primer/octicons-react' import { useState } from 'react' import MenuComponent from '~/components/Menu' -import { Machine, Route } from '~/types' +import { Machine, Route, User } from '~/types' import { cn } from '~/utils/cn' import Delete from './dialogs/delete' import Expire from './dialogs/expire' +import Move from './dialogs/move' import Rename from './dialogs/rename' import Routes from './dialogs/routes' interface MenuProps { machine: Machine routes: Route[] + users: User[] magic?: string } -export default function Menu({ machine, routes, magic }: MenuProps) { +export default function Menu({ machine, routes, magic, users }: MenuProps) { const renameState = useState(false) const expireState = useState(false) const removeState = useState(false) const routesState = useState(false) + const moveState = useState(false) const expired = machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01T00:00:00Z' @@ -51,6 +54,12 @@ export default function Menu({ machine, routes, magic }: MenuProps) { routes={routes} state={routesState} /> + Edit ACL tags + + Change owner + {expired ? undefined : ( diff --git a/app/routes/_data.machines._index/route.tsx b/app/routes/_data.machines._index/route.tsx index 7fd4e95..08b2568 100644 --- a/app/routes/_data.machines._index/route.tsx +++ b/app/routes/_data.machines._index/route.tsx @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { InfoIcon } from '@primer/octicons-react' -import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node' +import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' import { Button, Tooltip, TooltipTrigger } from 'react-aria-components' import Code from '~/components/Code' -import { type Machine, type Route } from '~/types' +import { type Machine, type Route, 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 { pull } from '~/utils/headscale' import { getSession } from '~/utils/sessions' import { useLiveData } from '~/utils/useLiveData' @@ -18,9 +18,10 @@ import MachineRow from './machine' export async function loader({ request }: LoaderFunctionArgs) { const session = await getSession(request.headers.get('Cookie')) - const [machines, routes] = await Promise.all([ + const [machines, routes, users] = await Promise.all([ pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!), + pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), ]) const context = await loadContext() @@ -36,6 +37,7 @@ export async function loader({ request }: LoaderFunctionArgs) { return { nodes: machines.nodes, routes: routes.routes, + users: users.users, magic, } } @@ -96,6 +98,7 @@ export default function Page() { key={machine.id} machine={machine} routes={data.routes.filter(route => route.node.id === machine.id)} + users={data.users} magic={data.magic} /> ))}