feat(users): expose rename and delete for unlinked Headscale users

The user-actions handler already supported `rename_user` and
`delete_user` (both keyed by headscale_user_id), but the Unlinked
Headscale Users table had no menu to invoke them. That left admins
with no UI path to remove or rename Headscale users that have no
Headplane counterpart — e.g. users created via the Headscale CLI
before an OIDC migration.

Wire the existing dialogs into a new HeadscaleUserMenu rendered in
the row's actions column. OIDC-managed users still can't be renamed
(Headscale rejects it), so the Rename item hides for those.

Closes #525

Amp-Thread-ID: https://ampcode.com/threads/T-019e7ae4-6862-760c-a3e7-239350eab71d
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Aarnav Tale
2026-05-30 18:49:27 -04:00
parent 0a51182eed
commit 8c508e0602
4 changed files with 64 additions and 5 deletions
@@ -0,0 +1,58 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
}
type Modal = "rename" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
const canRename = user.provider !== "oidc";
return (
<>
{modal === "rename" && canRename && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
}
@@ -4,12 +4,14 @@ import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
}
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
</tr>
);
}
+1 -1
View File
@@ -234,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} />
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
))}
</tbody>
</table>