From 0d0b5f88a144aadf4b8611aeaaca8c98d021e3c1 Mon Sep 17 00:00:00 2001 From: Paul Kronenwetter Date: Sat, 24 Jan 2026 17:30:48 -0500 Subject: [PATCH 1/2] chore(machines): conditionally show owner change menu item based on version Adds conditional rendering for the "Change owner" menu item in machine management, hiding it for versions 0.28.0-beta.1 and above. This prevents users from attempting to change node ownership on newer versions where this functionality is not supported. The change involves passing a new `supportsNodeOwnerChange` prop through the component hierarchy and updating the menu rendering logic accordingly. --- app/routes/machines/components/machine-row.tsx | 3 +++ app/routes/machines/components/menu.tsx | 4 +++- app/routes/machines/machine.tsx | 5 ++++- app/routes/machines/overview.tsx | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 9fae3d3..e463613 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -27,6 +27,7 @@ interface Props { magic?: string; isDisabled?: boolean; existingTags?: string[]; + supportsNodeOwnerChange: boolean; } export default function MachineRow({ @@ -36,6 +37,7 @@ export default function MachineRow({ magic, isDisabled, existingTags, + supportsNodeOwnerChange, }: Props) { const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]); @@ -142,6 +144,7 @@ export default function MachineRow({ magic={magic} node={node} users={users} + supportsNodeOwnerChange={supportsNodeOwnerChange} /> diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index cb0d8d6..f82f05f 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -19,6 +19,7 @@ interface MenuProps { isFullButton?: boolean; isDisabled?: boolean; existingTags?: string[]; + supportsNodeOwnerChange: boolean; } type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; @@ -30,6 +31,7 @@ export default function MachineMenu({ isFullButton, isDisabled, existingTags, + supportsNodeOwnerChange, }: MenuProps) { const [modal, setModal] = useState(null); const supportsTailscaleSSH = @@ -164,7 +166,7 @@ export default function MachineMenu({ Edit machine name Edit route settings Edit ACL tags - Change owner + {supportsNodeOwnerChange && Change owner} diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index db47ec1..777382b 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -44,6 +44,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { const lookup = await context.agents?.lookup([node.nodeKey]); const [enhancedNode] = mapNodes([node], lookup); const tags = [...node.tags].sort(); + const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); return { node: enhancedNode, @@ -53,13 +54,14 @@ export async function loader({ request, params, context }: Route.LoaderArgs) { agent: context.agents?.agentID(), stats: lookup?.[enhancedNode.nodeKey], existingTags: sortNodeTags(nodes), + supportsNodeOwnerChange: supportsNodeOwnerChange, }; } export const action = machineAction; export default function Page({ - loaderData: { node, tags, users, magic, agent, stats, existingTags }, + loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange }, }: Route.ComponentProps) { const [showRouting, setShowRouting] = useState(false); @@ -93,6 +95,7 @@ export default function Page({ magic={magic} node={node} users={users} + supportsNodeOwnerChange={supportsNodeOwnerChange} />
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index d22a596..b0a41d2 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -48,6 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey)); const populatedNodes = mapNodes(nodes, stats); + const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); return { populatedNodes, @@ -63,6 +64,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { Capabilities.generate_authkeys, ), subject: user.subject, + supportsNodeOwnerChange: supportsNodeOwnerChange, }; } @@ -380,6 +382,7 @@ export default function Page({ loaderData }: Route.ComponentProps) { magic={loaderData.magic} node={node} users={loaderData.users} + supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange} /> )) )} From b38b733e9f76c634b952b2d506e5b61f3719ce10 Mon Sep 17 00:00:00 2001 From: Paul Kronenwetter Date: Fri, 6 Feb 2026 13:54:46 -0500 Subject: [PATCH 2/2] Conditionally skip node reassignment test where version >= 0.28.0 --- tests/integration/nodes.test.ts | 16 +++++++++------- tests/integration/setup/env.ts | 5 +++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/integration/nodes.test.ts b/tests/integration/nodes.test.ts index f7170bb..5f1a9eb 100644 --- a/tests/integration/nodes.test.ts +++ b/tests/integration/nodes.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { getNode, getRuntimeClient, HS_VERSIONS } from './setup/env'; +import { getNode, getRuntimeClient, getIsAtLeast, HS_VERSIONS } from './setup/env'; describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => { let workingNodeId: string; @@ -41,13 +41,15 @@ describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => { }); test('nodes can be reassigned to another user', async () => { - const client = await getRuntimeClient(version); - const user = await client.createUser('node-reassign@'); + if (! getIsAtLeast("0.28.0")) { + const client = await getRuntimeClient(version); + const user = await client.createUser('node-reassign@'); - await client.setNodeUser(workingNodeId, user.id); - const reassignedNode = await client.getNode(workingNodeId); - expect(reassignedNode).toBeDefined(); - expect(reassignedNode.user.name).toBe(user.name); + await client.setNodeUser(workingNodeId, user.id); + const reassignedNode = await client.getNode(workingNodeId); + expect(reassignedNode).toBeDefined(); + expect(reassignedNode.user.name).toBe(user.name); + } }); test('nodes can be expired', async () => { diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts index 1185128..b0df62f 100644 --- a/tests/integration/setup/env.ts +++ b/tests/integration/setup/env.ts @@ -43,6 +43,11 @@ export async function getRuntimeClient(version: Version) { return bootstrap.getRuntimeClient(env.apiKey); } +export async function getIsAtLeast(version: Version) { + const { env, bootstrap } = await ensureVersion(version); + return bootstrap.clientHelpers.isAtleast; +} + export async function getNode(version: Version) { const { tailscaleNode } = await ensureVersion(version); return {