headplane/app/routes/users/overview.tsx

234 lines
5.9 KiB
TypeScript
Raw Normal View History

import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
2024-12-31 10:30:14 +05:30
import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
2024-12-31 10:31:50 +05:30
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
2025-02-19 13:01:20 -05:00
import { useLoaderData, useSubmit } from 'react-router';
2024-12-31 10:30:14 +05:30
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
2025-03-22 01:36:27 -04:00
import type { LoadContext } from '~/server';
2024-12-31 10:31:50 +05:30
import type { Machine, User } from '~/types';
2025-01-28 17:46:16 -05:00
import cn from '~/utils/cn';
2025-02-19 13:01:20 -05:00
import ManageBanner from './components/manage-banner';
import DeleteUser from './dialogs/delete-user';
import RenameUser from './dialogs/rename-user';
import { userAction } from './user-actions';
2024-05-15 21:52:19 -04:00
2025-03-22 01:36:27 -04:00
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
2024-05-15 21:52:19 -04:00
const [machines, apiUsers] = await Promise.all([
2025-03-22 01:36:27 -04:00
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
2024-12-31 10:30:14 +05:30
]);
2024-05-15 21:52:19 -04:00
2024-12-31 10:30:14 +05:30
const users = apiUsers.users.map((user) => ({
2024-05-15 21:52:19 -04:00
...user,
2024-12-31 10:30:14 +05:30
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
2024-05-15 21:52:19 -04:00
2024-12-31 10:30:14 +05:30
let magic: string | undefined;
2025-03-22 01:36:27 -04:00
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
2024-05-15 21:52:19 -04:00
}
}
return {
2025-03-22 01:36:27 -04:00
oidc: context.config.oidc,
2024-05-15 21:52:19 -04:00
magic,
users,
2024-12-31 10:30:14 +05:30
};
2024-05-15 21:52:19 -04:00
}
2025-02-19 13:01:20 -05:00
export async function action(data: ActionFunctionArgs) {
return userAction(data);
2024-05-15 21:52:19 -04:00
}
export default function Page() {
2024-12-31 10:30:14 +05:30
const data = useLoaderData<typeof loader>();
2025-02-19 13:01:20 -05:00
const [users, setUsers] = useState<UserMachine[]>(data.users);
2024-05-15 21:52:19 -04:00
2025-02-19 13:01:20 -05:00
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
2024-05-15 21:52:19 -04:00
useEffect(() => {
2024-12-31 10:30:14 +05:30
setUsers(data.users);
}, [data.users]);
2024-05-15 21:52:19 -04:00
return (
<>
2024-12-31 10:30:14 +05:30
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
2024-05-15 21:52:19 -04:00
<p className="mb-8 text-md">
2024-12-31 10:30:14 +05:30
Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership.
2024-05-15 21:52:19 -04:00
</p>
2025-02-19 13:01:20 -05:00
<ManageBanner oidc={data.oidc} />
2024-12-31 10:30:14 +05:30
<ClientOnly fallback={<Users users={users} />}>
2024-05-15 21:52:19 -04:00
{() => (
<InteractiveUsers
users={users}
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly>
</>
2024-12-31 10:30:14 +05:30
);
2024-05-15 21:52:19 -04:00
}
2024-12-31 10:30:14 +05:30
type UserMachine = User & { machines: Machine[] };
2024-05-15 21:52:19 -04:00
interface UserProps {
2024-12-31 10:30:14 +05:30
users: UserMachine[];
setUsers?: (users: UserMachine[]) => void;
magic?: string;
2024-05-15 21:52:19 -04:00
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
2024-12-31 10:30:14 +05:30
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
2024-05-15 21:52:19 -04:00
))}
</div>
2024-12-31 10:30:14 +05:30
);
2024-05-15 21:52:19 -04:00
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
2024-12-31 10:30:14 +05:30
const submit = useSubmit();
2024-05-15 21:52:19 -04:00
return (
2024-12-31 10:30:14 +05:30
<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();
2025-02-19 13:01:20 -05:00
data.append('action_id', 'change_owner');
data.append('user_id', over.id.toString());
data.append('node_id', reference.current.id);
2024-12-31 10:30:14 +05:30
submit(data, {
method: 'POST',
});
}}
2024-05-15 21:52:19 -04:00
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
2024-12-31 10:30:14 +05:30
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
2024-05-15 21:52:19 -04:00
))}
</div>
</DndContext>
2024-12-31 10:30:14 +05:30
);
2024-05-15 21:52:19 -04:00
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
2024-12-31 10:30:14 +05:30
});
2024-05-15 21:52:19 -04:00
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
2025-02-04 17:21:03 -05:00
'hover:bg-headplane-50 dark:hover:bg-headplane-950 rounded-xl',
2024-05-15 21:52:19 -04:00
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
2025-02-04 17:21:03 -05:00
<StatusCircle isOnline={machine.online} className="px-1 h-4 w-fit" />
2024-05-15 21:52:19 -04:00
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
2024-05-15 21:52:19 -04:00
value={machine.ipAddresses[0]}
/>
</div>
2024-12-31 10:30:14 +05:30
);
2024-05-15 21:52:19 -04:00
}
interface CardProps {
2024-12-31 10:30:14 +05:30
user: UserMachine;
magic?: string;
2024-05-15 21:52:19 -04:00
}
function UserCard({ user, magic }: CardProps) {
2024-05-15 21:52:19 -04:00
const { isOver, setNodeRef } = useDroppable({
id: user.name,
2024-12-31 10:30:14 +05:30
});
2024-05-15 21:52:19 -04:00
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
2025-02-04 17:21:03 -05:00
isOver ? 'bg-headplane-100 dark:bg-headplane-800' : '',
2024-05-15 21:52:19 -04:00
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
2024-12-31 10:30:14 +05:30
<span className="text-lg font-mono">{user.name}</span>
2024-05-15 21:52:19 -04:00
</div>
<div className="flex items-center gap-2">
2025-02-19 13:01:20 -05:00
<RenameUser user={user} />
2024-12-31 10:30:14 +05:30
{user.machines.length === 0 ? (
2025-02-19 13:01:20 -05:00
<DeleteUser user={user} />
2024-12-31 10:30:14 +05:30
) : undefined}
2024-05-15 21:52:19 -04:00
</div>
</div>
<div className="mt-4">
2024-12-31 10:30:14 +05:30
{user.machines.map((machine) => (
2024-05-15 21:52:19 -04:00
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
2024-12-31 10:30:14 +05:30
);
2024-05-15 21:52:19 -04:00
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}