mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 09:18:29 +00:00
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:
@@ -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 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 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.
|
- 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)).
|
- 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)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 cn from "~/utils/cn";
|
||||||
|
|
||||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||||
|
import HeadscaleUserMenu from "./headscale-user-menu";
|
||||||
|
|
||||||
interface HeadscaleUserRowProps {
|
interface HeadscaleUserRowProps {
|
||||||
user: UnlinkedHeadscaleUser;
|
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 isOnline = user.machines.some((machine) => machine.online);
|
||||||
const lastSeen = user.machines.reduce(
|
const lastSeen = user.machines.reduce(
|
||||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
(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>
|
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-0.5">
|
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
|
||||||
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
||||||
<HeadscaleUserRow key={user.id} user={user} />
|
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user