mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
feat: implement complete user control
This commit is contained in:
@@ -2,39 +2,42 @@ import { CopyIcon } from '@primer/octicons-react'
|
|||||||
|
|
||||||
import { toast } from './Toaster'
|
import { toast } from './Toaster'
|
||||||
|
|
||||||
type Properties = {
|
interface Props {
|
||||||
readonly name: string;
|
name: string
|
||||||
readonly value: string;
|
value: string
|
||||||
readonly isCopyable?: boolean;
|
isCopyable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attribute({ name, value, isCopyable }: Properties) {
|
export default function Attribute({ name, value, isCopyable }: Props) {
|
||||||
const canCopy = isCopyable ?? false
|
const canCopy = isCopyable ?? false
|
||||||
return (
|
return (
|
||||||
<dl className='flex gap-1 text-sm w-full'>
|
<dl className="flex gap-1 text-sm w-full">
|
||||||
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
|
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||||
{name}
|
{name}
|
||||||
</dt>
|
</dt>
|
||||||
|
|
||||||
{(canCopy ?? false) ? (
|
{canCopy
|
||||||
<button
|
? (
|
||||||
type='button'
|
<button
|
||||||
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
|
type="button"
|
||||||
onClick={async () => {
|
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||||
await navigator.clipboard.writeText(value)
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
toast(`Copied ${name}`)
|
onClick={async () => {
|
||||||
}}
|
await navigator.clipboard.writeText(value)
|
||||||
>
|
toast(`Copied ${name}`)
|
||||||
<dd className='min-w-0 truncate px-2 py-1'>
|
}}
|
||||||
|
>
|
||||||
|
<dd className="min-w-0 truncate px-2 py-1">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<dd className="min-w-0 truncate px-2 py-1">
|
||||||
{value}
|
{value}
|
||||||
</dd>
|
</dd>
|
||||||
<CopyIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
|
)}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<dd className='min-w-0 truncate px-2 py-1'>
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, Machine[]>()
|
|
||||||
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<typeof loader>()
|
|
||||||
useLiveData({ interval: 3000 })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
|
|
||||||
{data.map(user => (
|
|
||||||
<Card key={user.id} variant='flat'>
|
|
||||||
<div className='flex items-center gap-4'>
|
|
||||||
<PersonIcon className='w-6 h-6'/>
|
|
||||||
<span className='text-lg font-mono'>
|
|
||||||
{user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='py-4'>
|
|
||||||
{user.machines.map(machine => (
|
|
||||||
<div key={machine.id} className='flex items-center w-full gap-4'>
|
|
||||||
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
|
|
||||||
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add a new user
|
||||||
|
</Dialog.Button>
|
||||||
|
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add a new user
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Enter a username to create a new user.
|
||||||
|
{' '}
|
||||||
|
{magic
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
Since Magic DNS is enabled, machines will be
|
||||||
|
accessible via
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[machine].
|
||||||
|
{username.length > 0 ? username : '[username]'}
|
||||||
|
.
|
||||||
|
{magic}
|
||||||
|
</Code>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="create" />
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
placeholder="my-new-user"
|
||||||
|
name="username"
|
||||||
|
state={[username, setUsername]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||||
|
<HomeIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
Basic Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
Users are not managed externally.
|
||||||
|
Using OpenID Connect can create a better
|
||||||
|
experience when using Headscale.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/oidc"
|
||||||
|
name="Headscale OIDC Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||||
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
You can add, remove, and rename users here.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Add magic={magic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Context['oidcConfig']>
|
||||||
|
readonly magic: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Oidc({ oidc, magic }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||||
|
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
OpenID Connect
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
Users are managed through your
|
||||||
|
{' '}
|
||||||
|
<Link to={oidc.issuer} name="OIDC Provider">
|
||||||
|
OpenID Connect provider
|
||||||
|
</Link>
|
||||||
|
{'. '}
|
||||||
|
Groups and user information do not automatically sync.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/oidc"
|
||||||
|
name="Headscale OIDC Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||||
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
You can still add users manually, however it is recommended
|
||||||
|
that you manage users through your OIDC provider.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Add magic={magic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
control={dialogState}
|
||||||
|
className="rounded-full p-0 w-8 h-8"
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Dialog control={dialogState}>
|
||||||
|
<Dialog.Panel control={dialogState}>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Delete
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Are you sure you want to delete
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
{' '}
|
||||||
|
A deleted user cannot be recovered.
|
||||||
|
{magic
|
||||||
|
? (
|
||||||
|
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||||
|
{' '}
|
||||||
|
Since Magic DNS is enabled, machines
|
||||||
|
currently accessible via
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[machine].
|
||||||
|
{username}
|
||||||
|
.
|
||||||
|
{magic}
|
||||||
|
</Code>
|
||||||
|
{' '}
|
||||||
|
will become orphaned and inaccessible.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="delete" />
|
||||||
|
<input type="hidden" name="username" value={username} />
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
control={dialogState}
|
||||||
|
className="rounded-full p-0 w-8 h-8"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Dialog control={dialogState}>
|
||||||
|
<Dialog.Panel control={dialogState}>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Rename
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Enter a new username for
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
{magic
|
||||||
|
? (
|
||||||
|
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||||
|
{' '}
|
||||||
|
Since Magic DNS is enabled, machines
|
||||||
|
currently accessible via
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[machine].
|
||||||
|
{username}
|
||||||
|
.
|
||||||
|
{magic}
|
||||||
|
</Code>
|
||||||
|
{' '}
|
||||||
|
will now become accessible via
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[machine].
|
||||||
|
{newName.length > 0 ? newName : '[new-username]'}
|
||||||
|
.
|
||||||
|
{magic}
|
||||||
|
</Code>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="rename" />
|
||||||
|
<input type="hidden" name="old" value={username} />
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
placeholder="my-new-name"
|
||||||
|
name="new"
|
||||||
|
state={[newName, setNewName]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof loader>()
|
||||||
|
const [users, setUsers] = useState(data.users)
|
||||||
|
const actionData = useActionData<typeof action>()
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-medium mb-1.5">
|
||||||
|
Users
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-md">
|
||||||
|
Manage the users in your network and their permissions.
|
||||||
|
Tip: You can drag machines between users to change ownership.
|
||||||
|
</p>
|
||||||
|
{data.oidcConfig
|
||||||
|
? (
|
||||||
|
<Oidc
|
||||||
|
oidc={data.oidcConfig}
|
||||||
|
magic={data.magic}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Auth magic={data.magic} />
|
||||||
|
)}
|
||||||
|
<ClientOnly fallback={
|
||||||
|
// @ts-expect-error: JsonifyObject is weird
|
||||||
|
<Users users={users} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<InteractiveUsers
|
||||||
|
// @ts-expect-error: JsonifyObject is weird
|
||||||
|
users={users}
|
||||||
|
// @ts-expect-error: JsonifyObject is weird
|
||||||
|
setUsers={setUsers}
|
||||||
|
magic={data.magic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMachine = User & { machines: Machine[] }
|
||||||
|
|
||||||
|
interface UserProps {
|
||||||
|
users: UserMachine[]
|
||||||
|
setUsers?: (users: UserMachine[]) => void
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Users({ users, magic }: UserProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 auto-rows-min">
|
||||||
|
{users.map((user, i) => (
|
||||||
|
<UserCard
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isFirst={i === 0}
|
||||||
|
magic={magic}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext onDragEnd={(event) => {
|
||||||
|
const { over, active } = event
|
||||||
|
if (!over) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI optimistically
|
||||||
|
const newUsers = new Array<UserMachine>()
|
||||||
|
const reference = active.data as DataRef<Machine>
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4 auto-rows-min">
|
||||||
|
{users.map((user, i) => (
|
||||||
|
<UserCard
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
isFirst={i === 0}
|
||||||
|
magic={magic}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: machine.id,
|
||||||
|
data: machine,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center w-full gap-2 py-1',
|
||||||
|
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: transform
|
||||||
|
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||||
|
<Attribute
|
||||||
|
name={machine.givenName}
|
||||||
|
value={machine.ipAddresses[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
user: UserMachine
|
||||||
|
isFirst: boolean
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ user, isFirst, magic }: CardProps) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
|
id: user.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef}>
|
||||||
|
<Card
|
||||||
|
variant="flat"
|
||||||
|
className={cn(
|
||||||
|
'max-w-full w-full overflow-visible h-full',
|
||||||
|
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<PersonIcon className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-mono">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rename username={user.name} magic={magic} />
|
||||||
|
{isFirst
|
||||||
|
? undefined
|
||||||
|
: (
|
||||||
|
<Remove username={user.name} magic={magic} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{user.machines.map(machine => (
|
||||||
|
<MachineChip key={machine.id} machine={machine} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user