chore: switch to react-router v7
This commit is contained in:
parent
39504e2487
commit
aa9872a45b
101 changed files with 3825 additions and 6796 deletions
|
|
@ -1,12 +1,12 @@
|
|||
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react';
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
|
||||
import Add from '../dialogs/add'
|
||||
import Add from '../dialogs/add';
|
||||
|
||||
interface Props {
|
||||
readonly magic: string | undefined
|
||||
readonly magic: string | undefined;
|
||||
}
|
||||
|
||||
export default function Auth({ magic }: Props) {
|
||||
|
|
@ -15,14 +15,10 @@ export default function Auth({ magic }: Props) {
|
|||
<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>
|
||||
<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.
|
||||
{' '}
|
||||
Users are not managed externally. Using OpenID Connect can create a
|
||||
better experience when using Headscale.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
|
|
@ -33,9 +29,7 @@ export default function Auth({ magic }: Props) {
|
|||
</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>
|
||||
<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>
|
||||
|
|
@ -45,5 +39,5 @@ export default function Auth({ magic }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react';
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import { HeadplaneContext } from '~/utils/config/headplane';
|
||||
|
||||
import Add from '../dialogs/add'
|
||||
import Add from '../dialogs/add';
|
||||
|
||||
interface Props {
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>
|
||||
readonly magic: string | undefined
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>;
|
||||
readonly magic: string | undefined;
|
||||
}
|
||||
|
||||
export default function Oidc({ oidc, magic }: Props) {
|
||||
|
|
@ -17,18 +17,14 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||
<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>
|
||||
<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
|
||||
{' '}
|
||||
Users are managed through your{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.
|
||||
{' '}
|
||||
Groups and user information do not automatically sync.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
|
|
@ -39,12 +35,10 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||
</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>
|
||||
<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.
|
||||
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} />
|
||||
|
|
@ -52,5 +46,5 @@ export default function Oidc({ oidc, magic }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,39 @@
|
|||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
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'
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
|
||||
interface Props {
|
||||
magic?: string
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
export default function Add({ magic }: Props) {
|
||||
const [username, setUsername] = useState('')
|
||||
const submit = useSubmit()
|
||||
const [username, setUsername] = useState('');
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add a new user
|
||||
</Dialog.Button>
|
||||
<Dialog.Button>Add a new user</Dialog.Button>
|
||||
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add a new user
|
||||
</Dialog.Title>
|
||||
<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].
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
Enter a username to create a new user.{' '}
|
||||
{magic ? (
|
||||
<>
|
||||
Since Magic DNS is enabled, machines will be accessible via{' '}
|
||||
<Code>[machine]. .{magic}</Code>.
|
||||
</>
|
||||
) : undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
submit(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="create" />
|
||||
|
|
@ -59,16 +45,10 @@ export default function Add({ magic }: Props) {
|
|||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Create
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
|
|
@ -77,5 +57,5 @@ export default function Add({ magic }: Props) {
|
|||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { XIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
username: string;
|
||||
}
|
||||
|
||||
export default function Remove({ username }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
const submit = useSubmit();
|
||||
const dialogState = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -25,41 +25,26 @@ export default function Remove({ username }: Props) {
|
|||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{close => (
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Delete
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
</Dialog.Title>
|
||||
<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.
|
||||
Are you sure you want to delete {username}? A deleted user
|
||||
cannot be recovered.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
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}
|
||||
>
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Delete
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
|
|
@ -69,5 +54,5 @@ export default function Remove({ username }: Props) {
|
|||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import { PencilIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { PencilIcon } from '@primer/octicons-react';
|
||||
import { Form, useSubmit } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import Button from '~/components/Button';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import TextField from '~/components/TextField';
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
username: string;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
export default function Rename({ username, magic }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
const [newName, setNewName] = useState(username)
|
||||
const submit = useSubmit();
|
||||
const dialogState = useState(false);
|
||||
const [newName, setNewName] = useState(username);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -27,23 +27,16 @@ export default function Rename({ username, magic }: Props) {
|
|||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{close => (
|
||||
{(close) => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Rename
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
</Dialog.Title>
|
||||
<Dialog.Title>Rename {username}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Enter a new username for
|
||||
{' '}
|
||||
{username}
|
||||
Enter a new username for {username}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
submit(event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
|
|
@ -56,16 +49,10 @@ export default function Rename({ username, magic }: Props) {
|
|||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
<Dialog.Action variant="cancel" onPress={close}>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
<Dialog.Action variant="confirm" onPress={close}>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
|
|
@ -75,5 +62,5 @@ export default function Rename({ username, magic }: Props) {
|
|||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,48 @@
|
|||
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { PersonIcon } from '@primer/octicons-react'
|
||||
import { ActionFunctionArgs, 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 { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { PersonIcon } from '@primer/octicons-react';
|
||||
import { 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 { toast } from '~/components/Toaster'
|
||||
import { 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 { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
import { send } from '~/utils/res'
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Card from '~/components/Card';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { toast } from '~/components/Toaster';
|
||||
import { 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 { getSession } from '~/utils/sessions';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
import Auth from './components/auth'
|
||||
import Oidc from './components/oidc'
|
||||
import Remove from './dialogs/remove'
|
||||
import Rename from './dialogs/rename'
|
||||
import Auth from './components/auth';
|
||||
import Oidc from './components/oidc';
|
||||
import Remove from './dialogs/remove';
|
||||
import Rename from './dialogs/rename';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
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 => ({
|
||||
const users = apiUsers.users.map((user) => ({
|
||||
...user,
|
||||
machines: machines.nodes.filter(machine => machine.user.id === user.id),
|
||||
}))
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
}));
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
const context = await loadContext();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
const config = await loadConfig();
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
magic = config.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,125 +50,115 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||
oidc: context.oidc,
|
||||
magic,
|
||||
users,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ message: 'Unauthorized' }, 401)
|
||||
return send({ message: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
const data = await request.formData();
|
||||
if (!data.has('_method')) {
|
||||
return send({ message: 'No method provided' }, 400)
|
||||
return send({ message: 'No method provided' }, 400);
|
||||
}
|
||||
|
||||
const method = String(data.get('_method'))
|
||||
const method = String(data.get('_method'));
|
||||
|
||||
switch (method) {
|
||||
case 'create': {
|
||||
if (!data.has('username')) {
|
||||
return send({ message: 'No name provided' }, 400)
|
||||
return send({ message: 'No name provided' }, 400);
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
const username = String(data.get('username'));
|
||||
await post('v1/user', session.get('hsApiKey')!, {
|
||||
name: username,
|
||||
})
|
||||
});
|
||||
|
||||
return { message: `User ${username} created` }
|
||||
return { message: `User ${username} created` };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!data.has('username')) {
|
||||
return send({ message: 'No name provided' }, 400)
|
||||
return send({ message: 'No name provided' }, 400);
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
||||
return { message: `User ${username} deleted` }
|
||||
const username = String(data.get('username'));
|
||||
await del(`v1/user/${username}`, session.get('hsApiKey')!);
|
||||
return { message: `User ${username} deleted` };
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('old') || !data.has('new')) {
|
||||
return send({ message: 'No old or new name provided' }, 400)
|
||||
return send({ message: 'No old or new name provided' }, 400);
|
||||
}
|
||||
|
||||
const old = String(data.get('old'))
|
||||
const newName = String(data.get('new'))
|
||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
|
||||
return { message: `User ${old} renamed to ${newName}` }
|
||||
const old = String(data.get('old'));
|
||||
const newName = String(data.get('new'));
|
||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!);
|
||||
return { message: `User ${old} renamed to ${newName}` };
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||
return send({ message: 'No ID or destination provided' }, 400)
|
||||
return send({ message: 'No ID or destination provided' }, 400);
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const to = String(data.get('to'))
|
||||
const name = String(data.get('name'))
|
||||
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 { message: `Moved ${name} to ${to}` }
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!);
|
||||
return { message: `Moved ${name} to ${to}` };
|
||||
} catch {
|
||||
return send({ message: `Failed to move ${name} to ${to}` }, 500)
|
||||
return send({ message: `Failed to move ${name} to ${to}` }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send({ message: 'Invalid method' }, 400)
|
||||
return send({ message: 'Invalid method' }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [users, setUsers] = useState(data.users)
|
||||
const actionData = useActionData<typeof action>()
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [users, setUsers] = useState(data.users);
|
||||
const actionData = useActionData<typeof action>();
|
||||
useLiveData({ interval: 3000 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionData) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
toast(actionData.message)
|
||||
toast(actionData.message);
|
||||
if (actionData.message.startsWith('Failed')) {
|
||||
setUsers(data.users)
|
||||
setUsers(data.users);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionData])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionData]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsers(data.users)
|
||||
}, [data.users])
|
||||
setUsers(data.users);
|
||||
}, [data.users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-1.5">
|
||||
Users
|
||||
</h1>
|
||||
<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.
|
||||
Manage the users in your network and their permissions. Tip: You can
|
||||
drag machines between users to change ownership.
|
||||
</p>
|
||||
{data.oidc
|
||||
? (
|
||||
<Oidc
|
||||
oidc={data.oidc}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={
|
||||
<Users users={users} />
|
||||
}
|
||||
>
|
||||
{data.oidc ? (
|
||||
<Oidc oidc={data.oidc} magic={data.magic} />
|
||||
) : (
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={<Users users={users} />}>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
users={users}
|
||||
|
|
@ -178,92 +168,86 @@ export default function Page() {
|
|||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type UserMachine = User & { machines: Machine[] }
|
||||
type UserMachine = User & { machines: Machine[] };
|
||||
|
||||
interface UserProps {
|
||||
users: UserMachine[]
|
||||
setUsers?: (users: UserMachine[]) => void
|
||||
magic?: string
|
||||
users: UserMachine[];
|
||||
setUsers?: (users: UserMachine[]) => void;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} magic={magic} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
const submit = useSubmit()
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<DndContext onDragEnd={(event) => {
|
||||
const { over, active } = event
|
||||
if (!over) {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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),
|
||||
})
|
||||
}
|
||||
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)
|
||||
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',
|
||||
})
|
||||
}}
|
||||
submit(data, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} magic={magic} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: machine.id,
|
||||
data: machine,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -287,18 +271,18 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
|
|||
value={machine.ipAddresses[0]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine
|
||||
magic?: string
|
||||
user: UserMachine;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
|
|
@ -312,25 +296,21 @@ function UserCard({ user, magic }: CardProps) {
|
|||
<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>
|
||||
<span className="text-lg font-mono">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} />
|
||||
)
|
||||
: undefined}
|
||||
{user.machines.length === 0 ? (
|
||||
<Remove username={user.name} />
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{user.machines.map(machine => (
|
||||
{user.machines.map((machine) => (
|
||||
<MachineChip key={machine.id} machine={machine} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue