headplane/app/routes/users/overview.tsx
2025-01-06 08:19:40 +05:30

317 lines
8.0 KiB
TypeScript

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 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 [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 loadContext();
let magic: string | undefined;
if (context.config.read) {
const config = await loadConfig();
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
}
}
return {
oidc: context.oidc,
magic,
users,
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ message: 'Unauthorized' }, 401);
}
const data = await request.formData();
if (!data.has('_method')) {
return send({ message: 'No method provided' }, 400);
}
const method = String(data.get('_method'));
switch (method) {
case 'create': {
if (!data.has('username')) {
return send({ message: 'No name provided' }, 400);
}
const username = String(data.get('username'));
await post('v1/user', session.get('hsApiKey')!, {
name: username,
});
return { message: `User ${username} created` };
}
case 'delete': {
if (!data.has('username')) {
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` };
}
case 'rename': {
if (!data.has('old') || !data.has('new')) {
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}` };
}
case 'move': {
if (!data.has('id') || !data.has('to') || !data.has('name')) {
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'));
try {
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);
}
}
default: {
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 });
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.oidc ? (
<Oidc oidc={data.oidc} magic={data.magic} />
) : (
<Auth magic={data.magic} />
)}
<ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
users={users}
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-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user) => (
<UserCard key={user.id} user={user} 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-1 md:grid-cols-2 gap-4 auto-rows-min">
{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
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}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
);
}
interface CardProps {
user: UserMachine;
magic?: string;
}
function UserCard({ user, 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} />
{user.machines.length === 0 ? (
<Remove username={user.name} />
) : undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
);
}