From 8c508e0602fc5e5be0a33cee6cab04cafc9bda94 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 30 May 2026 18:49:27 -0400 Subject: [PATCH] feat(users): expose rename and delete for unlinked Headscale users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 1 + .../users/components/headscale-user-menu.tsx | 58 +++++++++++++++++++ .../users/components/headscale-user-row.tsx | 8 +-- app/routes/users/overview.tsx | 2 +- 4 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 app/routes/users/components/headscale-user-menu.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a752a64..4272c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)). - Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` that is looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)). - Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id. +- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)). - Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)). --- diff --git a/app/routes/users/components/headscale-user-menu.tsx b/app/routes/users/components/headscale-user-menu.tsx new file mode 100644 index 0000000..5012f74 --- /dev/null +++ b/app/routes/users/components/headscale-user-menu.tsx @@ -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(null); + + // Headscale-managed OIDC users cannot be renamed via the API. + const canRename = user.provider !== "oidc"; + + return ( + <> + {modal === "rename" && canRename && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + {modal === "delete" && ( + { + if (!isOpen) setModal(null); + }} + user={user} + /> + )} + + + + + + + {canRename && setModal("rename")}>Rename} + {canRename && } + setModal("delete")}> + Delete + + + + + ); +} diff --git a/app/routes/users/components/headscale-user-row.tsx b/app/routes/users/components/headscale-user-row.tsx index 647ec70..8d74d28 100644 --- a/app/routes/users/components/headscale-user-row.tsx +++ b/app/routes/users/components/headscale-user-row.tsx @@ -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) {

No machines

)} - - {/* Unlinked users only get basic Headscale operations (rename, delete) */} - + {writable ? : null} ); } diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index f2591d5..a4a9f87 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -234,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { )} > {loaderData.unlinkedHeadscaleUsers.map((user) => ( - + ))}