import { useState } from 'react'; import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react'; import type { HeadscaleUserWithCounts } from './shared'; import { useHeadscaleUsers } from './useHeadscaleData'; import { headscaleErrorMessage } from './useHeadscaleServers'; import { timeAgo } from './format'; import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards'; import { ViewShell, EmptyBody } from './ViewShell'; // The users section. A Headscale user is a namespace that owns nodes and pre-auth keys — not a login. // // The node count next to each user is the point of this screen: Headscale refuses to delete a user that // still owns nodes, and without the count that refusal arrives as a surprise after the confirm click. type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void }; const UserRow = ({ user, onError }: UserRowProps) => { const { rename, remove } = useHeadscaleUsers(); const [renaming, setRenaming] = useState(false); const [draft, setDraft] = useState(user.name); const [confirming, setConfirming] = useState(false); const busy = rename.isPending || remove.isPending; const run = async (fn: () => Promise) => { try { await fn(); } catch (err) { onError(headscaleErrorMessage(err)); } }; const submitRename = async () => { const name = draft.trim(); setRenaming(false); if (!name || name === user.name) return; await run(() => rename.mutateAsync({ id: user.id, name })); }; return (
0 ? 'ok' : 'idle'} />
{renaming ? (
setDraft(ev.target.value)} onKeyDown={(ev) => { if (ev.key === 'Enter') void submitRename(); if (ev.key === 'Escape') setRenaming(false); }} autoFocus spellCheck={false} className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 text-sm text-zinc-100 outline-none focus:border-primary/50" />
) : (
{user.name} {user.provider && {user.provider}}
)}
{user.nodeCount} node{user.nodeCount === 1 ? '' : 's'} {user.onlineCount > 0 && `, ${user.onlineCount} online`} {user.email && · {user.email}} · created {timeAgo(user.createdAt)}
{confirming ? ( <> ) : ( )}
); }; const CreateUserForm = ({ onClose }: { onClose: () => void }) => { const { create } = useHeadscaleUsers(); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [error, setError] = useState(null); const submit = async () => { setError(null); if (!name.trim()) return setError('A name is required'); try { await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined }); onClose(); } catch (err) { setError(headscaleErrorMessage(err)); } }; return (
{ ev.preventDefault(); void submit(); }} className="flex flex-col gap-3 p-4" >
New user
{error && {error}}
); }; export const UsersView = () => { const { users, isLoading, error } = useHeadscaleUsers(); const [creating, setCreating] = useState(false); const [actionError, setActionError] = useState(null); return (

Users

Namespaces that own nodes and pre-auth keys.

{!creating && ( )}
{creating && setCreating(false)} />} {actionError && {actionError}} {users.length === 0 && !creating && ( } title="No users yet" hint="Every node belongs to a user. Create one before issuing a pre-auth key." /> )} {users.map((user) => ( ))}
); };