diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index a0d2eaad..9cec55cb 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -1,11 +1,12 @@ import { Cog, Ellipsis, SquareTerminal } from "lucide-react"; import { useState } from "react"; +import { useSubmit } from "react-router"; import Button from "~/components/button"; import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu"; import type { User } from "~/types"; import cn from "~/utils/cn"; -import { PopulatedNode } from "~/utils/node-info"; +import { isNoExpiry, type PopulatedNode } from "~/utils/node-info"; import Delete from "../dialogs/delete"; import Expire from "../dialogs/expire"; @@ -35,6 +36,7 @@ export default function MachineMenu({ existingTags, supportsNodeOwnerChange, }: MenuProps) { + const submit = useSubmit(); const [modal, setModal] = useState(null); const supportsTailscaleSSH = node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0; @@ -156,15 +158,31 @@ export default function MachineMenu({ setModal("rename")}>Edit machine name + + submit( + { + action_id: "toggle_expiry", + node_id: node.id, + disableExpiry: !isNoExpiry(node.expiry), + }, + { method: "post" }, + ) + } + > + {isNoExpiry(node.expiry) ? "Enable" : "Disable"} key expiry + setModal("routes")}>Edit route settings setModal("tags")}>Edit ACL tags {supportsNodeOwnerChange && ( setModal("move")}>Change owner )} - setModal("expire")}> - Expire - + {!isNoExpiry(node.expiry) && ( + setModal("expire")}> + Expire + + )} setModal("remove")}> Remove diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 452beb31..ee80494e 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -94,6 +94,13 @@ export async function machineAction({ request, context }: Route.ActionArgs) { return { message: "Machine expired" }; } + case "toggle_expiry": { + const disableExpiry = String(formData.get("disableExpiry")) === "true"; + await api.toggleExpiry(nodeId, disableExpiry); + await context.hsLive.refresh(nodesResource, api); + return { message: "Machine expired" }; + } + case "update_tags": { const tags = formData.get("tags")?.toString().split(",") ?? []; if (tags.length === 0) { diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts index 8fd8144e..a9ab0f91 100644 --- a/app/server/headscale/api/endpoints/nodes.ts +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -74,6 +74,14 @@ export interface NodeEndpoints { */ expireNode(id: string): Promise; + /** + * Toggle expiry of a specific node (machine) by its ID. + * + * @param id The ID of the node to expire. + * @param disableExpiry `true` if machine shall have key expiry disabled, `false` otherwise. + */ + toggleExpiry(id: string, disableExpiry: boolean): Promise; + /** * Renames a specific node (machine) by its ID. * @@ -144,6 +152,14 @@ export default defineApiEndpoints((client, apiKey) => ({ await client.apiFetch("POST", `v1/node/${nodeId}/expire`, apiKey); }, + toggleExpiry: async (nodeId, disableExpiry) => { + await client.apiFetch( + "POST", + `v1/node/${nodeId}/expire?disableExpiry=${disableExpiry}`, + apiKey, + ); + }, + renameNode: async (nodeId, newName) => { await client.apiFetch("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey); }, diff --git a/tests/integration/api/nodes.test.ts b/tests/integration/api/nodes.test.ts index 7565dd90..772f1646 100644 --- a/tests/integration/api/nodes.test.ts +++ b/tests/integration/api/nodes.test.ts @@ -65,6 +65,21 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => { expect(expiredNode.expiry).toBeDefined(); }); + test("key expiry of nodes can be toggled", async () => { + const client = await getRuntimeClient(version); + await client.toggleExpiry(workingNodeId, true); + + const permanentNode = await client.getNode(workingNodeId); + expect(permanentNode).toBeDefined(); + expect(permanentNode.expiry).toBeNull(); + + await client.toggleExpiry(workingNodeId, false); + + const node = await client.getNode(workingNodeId); + expect(node).toBeDefined(); + expect(node.expiry).not.toBeNull(); + }); + test("nodes can be deleted", async () => { const client = await getRuntimeClient(version); await client.deleteNode(workingNodeId);