From e6eba645c49f889a4f86d44fc1b8e452771fa8bb Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Wed, 15 May 2024 21:52:19 -0400 Subject: [PATCH] feat: implement complete user control --- app/components/Attribute.tsx | 51 ++-- app/routes/_data.users._index.tsx | 66 ----- app/routes/_data.users._index/add.tsx | 82 ++++++ app/routes/_data.users._index/auth.tsx | 49 ++++ app/routes/_data.users._index/oidc.tsx | 56 ++++ app/routes/_data.users._index/remove.tsx | 92 ++++++ app/routes/_data.users._index/rename.tsx | 107 +++++++ app/routes/_data.users._index/route.tsx | 356 +++++++++++++++++++++++ 8 files changed, 769 insertions(+), 90 deletions(-) delete mode 100644 app/routes/_data.users._index.tsx create mode 100644 app/routes/_data.users._index/add.tsx create mode 100644 app/routes/_data.users._index/auth.tsx create mode 100644 app/routes/_data.users._index/oidc.tsx create mode 100644 app/routes/_data.users._index/remove.tsx create mode 100644 app/routes/_data.users._index/rename.tsx create mode 100644 app/routes/_data.users._index/route.tsx diff --git a/app/components/Attribute.tsx b/app/components/Attribute.tsx index 07e7ca8..31ac667 100644 --- a/app/components/Attribute.tsx +++ b/app/components/Attribute.tsx @@ -2,39 +2,42 @@ import { CopyIcon } from '@primer/octicons-react' import { toast } from './Toaster' -type Properties = { - readonly name: string; - readonly value: string; - readonly isCopyable?: boolean; +interface Props { + name: string + value: string + isCopyable?: boolean } -export default function Attribute({ name, value, isCopyable }: Properties) { +export default function Attribute({ name, value, isCopyable }: Props) { const canCopy = isCopyable ?? false return ( -
-
+
+
{name}
- {(canCopy ?? false) ? ( - + ) + : ( +
{value}
- - - ) : ( -
- {value} -
- )} + )}
) } diff --git a/app/routes/_data.users._index.tsx b/app/routes/_data.users._index.tsx deleted file mode 100644 index 11c09a6..0000000 --- a/app/routes/_data.users._index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/* eslint-disable unicorn/filename-case */ -import { PersonIcon } from '@primer/octicons-react' -import { type LoaderFunctionArgs } from '@remix-run/node' -import { useLoaderData } from '@remix-run/react' - -import Attribute from '~/components/Attribute' -import Card from '~/components/Card' -import StatusCircle from '~/components/StatusCircle' -import { type Machine } from '~/types' -import { pull } from '~/utils/headscale' -import { getSession } from '~/utils/sessions' -import { useLiveData } from '~/utils/useLiveData' - -export async function loader({ request }: LoaderFunctionArgs) { - const session = await getSession(request.headers.get('Cookie')) - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!) - - const users = new Map() - for (const machine of data.nodes) { - const { user } = machine - if (!users.has(user.id)) { - users.set(user.id, []) - } - - users.get(user.id)?.push(machine) - } - - return [...users.values()].map(machines => { - const { user } = machines[0] - - return { - ...user, - machines - } - }) -} - -export default function Page() { - const data = useLoaderData() - useLiveData({ interval: 3000 }) - - return ( -
- {data.map(user => ( - -
- - - {user.name} - -
-
- {user.machines.map(machine => ( -
- - -
- ))} -
-
- ))} -
- ) -} diff --git a/app/routes/_data.users._index/add.tsx b/app/routes/_data.users._index/add.tsx new file mode 100644 index 0000000..05b9781 --- /dev/null +++ b/app/routes/_data.users._index/add.tsx @@ -0,0 +1,82 @@ +import { Form, useSubmit } from '@remix-run/react' +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() + + 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]. + {username.length > 0 ? username : '[username]'} + . + {magic} + + . + + ) + : undefined} + +
{ + submit(event.currentTarget) + }} + > + + +
+ + Cancel + + + Create + +
+ + + )} +
+
+ ) +} diff --git a/app/routes/_data.users._index/auth.tsx b/app/routes/_data.users._index/auth.tsx new file mode 100644 index 0000000..689dce0 --- /dev/null +++ b/app/routes/_data.users._index/auth.tsx @@ -0,0 +1,49 @@ +import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react' + +import Card from '~/components/Card' +import Link from '~/components/Link' + +import Add from './add' + +interface Props { + readonly magic: string | undefined +} + +export default function Auth({ magic }: Props) { + return ( + +
+
+ +

+ Basic Authentication +

+

+ Users are not managed externally. + Using OpenID Connect can create a better + experience when using Headscale. + {' '} + + Learn more + +

+
+
+ +

+ User Management +

+

+ You can add, remove, and rename users here. +

+
+ +
+
+
+
+ ) +} diff --git a/app/routes/_data.users._index/oidc.tsx b/app/routes/_data.users._index/oidc.tsx new file mode 100644 index 0000000..8017d16 --- /dev/null +++ b/app/routes/_data.users._index/oidc.tsx @@ -0,0 +1,56 @@ +import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react' + +import Card from '~/components/Card' +import Link from '~/components/Link' +import { type Context } from '~/utils/config' + +import Add from './add' + +interface Props { + readonly oidc: NonNullable + readonly magic: string | undefined +} + +export default function Oidc({ oidc, magic }: Props) { + return ( + +
+
+ +

+ OpenID Connect +

+

+ Users are managed through your + {' '} + + OpenID Connect provider + + {'. '} + Groups and user information do not automatically sync. + {' '} + + Learn more + +

+
+
+ +

+ User Management +

+

+ You can still add users manually, however it is recommended + that you manage users through your OIDC provider. +

+
+ +
+
+
+
+ ) +} diff --git a/app/routes/_data.users._index/remove.tsx b/app/routes/_data.users._index/remove.tsx new file mode 100644 index 0000000..e90d575 --- /dev/null +++ b/app/routes/_data.users._index/remove.tsx @@ -0,0 +1,92 @@ +import { XIcon } from '@primer/octicons-react' +import { Form, useSubmit } from '@remix-run/react' +import { useState } from 'react' + +import Button from '~/components/Button' +import Code from '~/components/Code' +import Dialog from '~/components/Dialog' + +interface Props { + username: string + magic?: string +} + +export default function Remove({ username, magic }: Props) { + const submit = useSubmit() + const dialogState = useState(false) + + return ( + <> + + + + {close => ( + <> + + Delete + {' '} + {username} + ? + + + Are you sure you want to delete + {' '} + {username} + ? + {' '} + A deleted user cannot be recovered. + {magic + ? ( +

+ {' '} + Since Magic DNS is enabled, machines + currently accessible via + {' '} + + [machine]. + {username} + . + {magic} + + {' '} + will become orphaned and inaccessible. +

+ ) + : undefined} +
+
{ + submit(event.currentTarget) + }} + > + + +
+ + Cancel + + + Delete + +
+
+ + )} +
+
+ + ) +} diff --git a/app/routes/_data.users._index/rename.tsx b/app/routes/_data.users._index/rename.tsx new file mode 100644 index 0000000..b4a79d1 --- /dev/null +++ b/app/routes/_data.users._index/rename.tsx @@ -0,0 +1,107 @@ +import { PencilIcon } from '@primer/octicons-react' +import { Form, useSubmit } from '@remix-run/react' +import { useState } from 'react' + +import Button from '~/components/Button' +import Code from '~/components/Code' +import Dialog from '~/components/Dialog' +import TextField from '~/components/TextField' + +interface Props { + username: string + magic?: string +} + +export default function Rename({ username, magic }: Props) { + const submit = useSubmit() + const dialogState = useState(false) + const [newName, setNewName] = useState('') + + return ( + <> + + + + {close => ( + <> + + Rename + {' '} + {username} + ? + + + Enter a new username for + {' '} + {username} + ? + {magic + ? ( +

+ {' '} + Since Magic DNS is enabled, machines + currently accessible via + {' '} + + [machine]. + {username} + . + {magic} + + {' '} + will now become accessible via + {' '} + + [machine]. + {newName.length > 0 ? newName : '[new-username]'} + . + {magic} + + . +

+ ) + : undefined} +
+
{ + submit(event.currentTarget) + }} + > + + + +
+ + Cancel + + + Rename + +
+ + + )} +
+
+ + ) +} diff --git a/app/routes/_data.users._index/route.tsx b/app/routes/_data.users._index/route.tsx new file mode 100644 index 0000000..1d219ac --- /dev/null +++ b/app/routes/_data.users._index/route.tsx @@ -0,0 +1,356 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core' +import { PersonIcon } from '@primer/octicons-react' +import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node' +import { useActionData, useLoaderData, useSubmit } from '@remix-run/react' +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 { toast } from '~/components/Toaster' +import { type Machine, type User } from '~/types' +import { cn } from '~/utils/cn' +import { getConfig, getContext } from '~/utils/config' +import { del, post, pull } from '~/utils/headscale' +import { getSession } from '~/utils/sessions' +import { useLiveData } from '~/utils/useLiveData' + +import Auth from './auth' +import Oidc from './oidc' +import Remove from './remove' +import Rename from './rename' + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + + const [machines, apiUsers] = await Promise.all([ + pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!), + pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!), + ]) + + const users = apiUsers.users.map(user => ({ + ...user, + machines: machines.nodes.filter(machine => machine.user.id === user.id), + })) + + const context = await getContext() + let magic: string | undefined + + if (context.hasConfig) { + const config = await getConfig() + if (config.dns_config.magic_dns) { + magic = config.dns_config.base_domain + } + } + + return { + oidcConfig: context.oidcConfig, + magic, + users, + } +} + +export async function action({ request }: ActionFunctionArgs) { + const session = await getSession(request.headers.get('Cookie')) + if (!session.has('hsApiKey')) { + return json({ message: 'Unauthorized' }, { + status: 401, + }) + } + + const data = await request.formData() + if (!data.has('_method')) { + return json({ message: 'No method provided' }, { + status: 400, + }) + } + + const method = String(data.get('_method')) + + switch (method) { + case 'create': { + if (!data.has('username')) { + return json({ message: 'No name provided' }, { + status: 400, + }) + } + + const username = String(data.get('username')) + await post('v1/user', session.get('hsApiKey')!, { + name: username, + }) + + return json({ message: `User ${username} created` }) + } + + case 'delete': { + if (!data.has('username')) { + return json({ message: 'No name provided' }, { + status: 400, + }) + } + + const username = String(data.get('username')) + await del(`v1/user/${username}`, session.get('hsApiKey')!) + return json({ message: `User ${username} deleted` }) + } + + case 'rename': { + if (!data.has('old') || !data.has('new')) { + return json({ message: 'No old or new name provided' }, { + status: 400, + }) + } + + const old = String(data.get('old')) + const newName = String(data.get('new')) + await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!) + return json({ message: `User ${old} renamed to ${newName}` }) + } + + case 'move': { + if (!data.has('id') || !data.has('to') || !data.has('name')) { + return json({ message: 'No ID or destination provided' }, { + status: 400, + }) + } + + const id = String(data.get('id')) + const to = String(data.get('to')) + const name = String(data.get('name')) + + try { + await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!) + return json({ message: `Moved ${name} to ${to}` }) + } catch { + return json({ message: `Failed to move ${name} to ${to}` }, { + status: 500, + }) + } + } + + default: { + return json({ message: 'Invalid method' }, { + status: 400, + }) + } + } +} + +export default function Page() { + const data = useLoaderData() + const [users, setUsers] = useState(data.users) + const actionData = useActionData() + useLiveData({ interval: 3000 }) + + useEffect(() => { + if (!actionData) { + return + } + + toast(actionData.message) + if (actionData.message.startsWith('Failed')) { + setUsers(data.users) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [actionData]) + + useEffect(() => { + setUsers(data.users) + }, [data.users]) + + return ( + <> +

+ Users +

+

+ Manage the users in your network and their permissions. + Tip: You can drag machines between users to change ownership. +

+ {data.oidcConfig + ? ( + + ) + : ( + + )} + + } + > + {() => ( + + )} + + + ) +} + +type UserMachine = User & { machines: Machine[] } + +interface UserProps { + users: UserMachine[] + setUsers?: (users: UserMachine[]) => void + magic?: string +} + +function Users({ users, magic }: UserProps) { + return ( +
+ {users.map((user, i) => ( + + ))} +
+ ) +} + +function InteractiveUsers({ users, setUsers, magic }: UserProps) { + const submit = useSubmit() + + return ( + { + const { over, active } = event + if (!over) { + return + } + + // Update the UI optimistically + const newUsers = new Array() + const reference = active.data as DataRef + if (!reference.current) { + return + } + + // Ignore if the user is unchanged + if (reference.current.user.name === over.id) { + return + } + + for (const user of users) { + newUsers.push({ + ...user, + machines: over.id === user.name + ? [...user.machines, reference.current] + : user.machines.filter(m => m.id !== active.id), + }) + } + + setUsers?.(newUsers) + const data = new FormData() + data.append('_method', 'move') + data.append('id', active.id.toString()) + data.append('to', over.id.toString()) + data.append('name', reference.current.givenName) + + submit(data, { + method: 'POST', + }) + }} + > +
+ {users.map((user, i) => ( + + ))} +
+
+ ) +} + +function MachineChip({ machine }: { readonly machine: Machine }) { + const { attributes, listeners, setNodeRef, transform } = useDraggable({ + id: machine.id, + data: machine, + }) + + return ( +
+ + +
+ ) +} + +interface CardProps { + user: UserMachine + isFirst: boolean + magic?: string +} + +function UserCard({ user, isFirst, magic }: CardProps) { + const { isOver, setNodeRef } = useDroppable({ + id: user.name, + }) + + return ( +
+ +
+
+ + + {user.name} + +
+
+ + {isFirst + ? undefined + : ( + + )} +
+
+
+ {user.machines.map(machine => ( + + ))} +
+
+
+ ) +}