From badefc7f85e039fde6df32e6cc9d43723d574160 Mon Sep 17 00:00:00 2001 From: drifterza Date: Tue, 24 Feb 2026 13:32:41 +0200 Subject: [PATCH] fix: handle null user for tag-only nodes in Headscale 0.28+ In Headscale 0.28+, nodes can be registered with tag-only preauth keys which have no associated user. This change updates the Machine type to make user optional and adds null checks throughout the codebase where node.user is accessed. Fixes tag node operations (rename, add tags) that previously failed with 'Unexpected Server Error' when accessing node.user.providerId on nodes without a user association. Refs: #432 --- .../machines/components/machine-row.tsx | 4 +- app/routes/machines/dialogs/move.tsx | 76 +- app/routes/machines/machine-actions.ts | 4 +- app/routes/machines/machine.tsx | 12 +- app/routes/machines/overview.tsx | 723 +++++++++--------- app/routes/users/onboarding.tsx | 600 +++++++-------- app/routes/users/overview.tsx | 243 +++--- app/types/Machine.ts | 3 +- 8 files changed, 822 insertions(+), 843 deletions(-) diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index e463613..d748404 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -63,7 +63,9 @@ export default function MachineRow({ {node.givenName}

- {node.user.name || node.user.displayName || node.user.email || node.user.id} + {node.user + ? node.user.name || node.user.displayName || node.user.email || node.user.id + : "Tag-owned"}

{mapTagsToComponents(node, uiTags)} diff --git a/app/routes/machines/dialogs/move.tsx b/app/routes/machines/dialogs/move.tsx index 3585013..0cc41f6 100644 --- a/app/routes/machines/dialogs/move.tsx +++ b/app/routes/machines/dialogs/move.tsx @@ -1,45 +1,45 @@ -import { Key, useState } from 'react'; -import Dialog from '~/components/Dialog'; -import Select from '~/components/Select'; -import type { Machine, User } from '~/types'; +import { Key, useState } from "react"; + +import type { Machine, User } from "~/types"; + +import Dialog from "~/components/Dialog"; +import Select from "~/components/Select"; interface MoveProps { - machine: Machine; - users: User[]; - isOpen: boolean; - setIsOpen: (isOpen: boolean) => void; + machine: Machine; + users: User[]; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; } export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) { - const [userId, setUserId] = useState(machine.user.id); + const [userId, setUserId] = useState(machine.user?.id ?? null); - return ( - - - Change the owner of {machine.givenName} - - The owner of the machine is the user associated with it. - - - - - - - - ); + return ( + + + Change the owner of {machine.givenName} + The owner of the machine is the user associated with it. + + + + + + + ); } diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 831d57c..8e80ad0 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -60,7 +60,9 @@ export async function machineAction({ request, context }: Route.ActionArgs) { }); } - if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) { + // Tag-only nodes (Headscale 0.28+) have no user, so we rely on role-based permissions + const nodeOwnerId = node.user?.providerId?.split("/").pop(); + if (nodeOwnerId !== session.user.subject && !check) { throw data("You do not have permission to act on this machine", { status: 403, }); diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index 777382b..f1eff1c 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -44,7 +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"); + const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); return { node: enhancedNode, @@ -109,7 +109,9 @@ export default function Page({
- {node.user.name || node.user.displayName || node.user.email || node.user.id} + {node.user + ? node.user.name || node.user.displayName || node.user.email || node.user.id + : "Tag-owned"}
@@ -241,7 +243,11 @@ export default function Page({
node.nodeKey)); - const populatedNodes = mapNodes(nodes, stats); - const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); + 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, - nodes, - users, - magic, - server: context.config.headscale.url, - publicServer: context.config.headscale.public_url, - agent: context.agents?.agentID(), - writable: writablePermission, - preAuth: await context.sessions.check( - request, - Capabilities.generate_authkeys, - ), - subject: user.subject, - supportsNodeOwnerChange: supportsNodeOwnerChange, - }; + return { + populatedNodes, + nodes, + users, + magic, + server: context.config.headscale.url, + publicServer: context.config.headscale.public_url, + agent: context.agents?.agentID(), + writable: writablePermission, + preAuth: await context.sessions.check(request, Capabilities.generate_authkeys), + subject: user.subject, + supportsNodeOwnerChange: supportsNodeOwnerChange, + }; } export const action = machineAction; -type SortField = 'name' | 'ip' | 'version' | 'lastSeen'; +type SortField = "name" | "ip" | "version" | "lastSeen"; export default function Page({ loaderData }: Route.ComponentProps) { - const [searchQuery, setSearchQuery] = useState(''); - const [sortField, setSortField] = useState('name'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [searchQuery, setSearchQuery] = useState(""); + const [sortField, setSortField] = useState("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); - const filteredAndSortedNodes = useMemo(() => { - const query = searchQuery.toLowerCase().trim(); + const filteredAndSortedNodes = useMemo(() => { + const query = searchQuery.toLowerCase().trim(); - let nodes = loaderData.populatedNodes.filter((node) => { - if (!query) return true; - if (node.givenName.toLowerCase().includes(query)) return true; - if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) - return true; - return false; - }); + let nodes = loaderData.populatedNodes.filter((node) => { + if (!query) return true; + if (node.givenName.toLowerCase().includes(query)) return true; + if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true; + return false; + }); - nodes = [...nodes].sort((a, b) => { - let comparison = 0; + nodes = [...nodes].sort((a, b) => { + let comparison = 0; - switch (sortField) { - case 'name': - comparison = a.givenName.localeCompare(b.givenName); - break; - case 'ip': { - const getIPv4 = (addresses: string[]) => - addresses.find((ip) => !ip.includes(':')) || addresses[0] || ''; - const ipA = getIPv4(a.ipAddresses); - const ipB = getIPv4(b.ipAddresses); + switch (sortField) { + case "name": + comparison = a.givenName.localeCompare(b.givenName); + break; + case "ip": { + const getIPv4 = (addresses: string[]) => + addresses.find((ip) => !ip.includes(":")) || addresses[0] || ""; + const ipA = getIPv4(a.ipAddresses); + const ipB = getIPv4(b.ipAddresses); - if (!ipA.includes(':') && !ipB.includes(':')) { - const octetsA = ipA.split('.').map(Number); - const octetsB = ipB.split('.').map(Number); - for (let i = 0; i < 4; i++) { - if (octetsA[i] !== octetsB[i]) { - comparison = octetsA[i] - octetsB[i]; - break; - } - } - } else { - comparison = ipA.localeCompare(ipB); - } - break; - } - case 'version': { - const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0'; - const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0'; - const partsA = versionA.split('.').map(Number); - const partsB = versionB.split('.').map(Number); - const maxLen = Math.max(partsA.length, partsB.length); + if (!ipA.includes(":") && !ipB.includes(":")) { + const octetsA = ipA.split(".").map(Number); + const octetsB = ipB.split(".").map(Number); + for (let i = 0; i < 4; i++) { + if (octetsA[i] !== octetsB[i]) { + comparison = octetsA[i] - octetsB[i]; + break; + } + } + } else { + comparison = ipA.localeCompare(ipB); + } + break; + } + case "version": { + const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0"; + const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0"; + const partsA = versionA.split(".").map(Number); + const partsB = versionB.split(".").map(Number); + const maxLen = Math.max(partsA.length, partsB.length); - for (let i = 0; i < maxLen; i++) { - const segA = partsA[i] || 0; - const segB = partsB[i] || 0; - if (segA !== segB) { - comparison = segA - segB; - break; - } - } - break; - } - case 'lastSeen': - if (a.online !== b.online) { - comparison = a.online ? 1 : -1; - break; - } - comparison = - new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime(); - break; - } + for (let i = 0; i < maxLen; i++) { + const segA = partsA[i] || 0; + const segB = partsB[i] || 0; + if (segA !== segB) { + comparison = segA - segB; + break; + } + } + break; + } + case "lastSeen": + if (a.online !== b.online) { + comparison = a.online ? 1 : -1; + break; + } + comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime(); + break; + } - return sortDirection === 'asc' ? comparison : -comparison; - }); + return sortDirection === "asc" ? comparison : -comparison; + }); - return nodes; - }, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]); + return nodes; + }, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]); - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); - } else { - setSortField(field); - setSortDirection('asc'); - } - }; + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; - return ( - <> -
-
-

Machines

-

- Manage the devices connected to your Tailnet.{' '} - - Learn more - -

-
- -
-
-
- setSearchQuery(value.slice(0, 100))} - placeholder="Search by name or IP address..." - value={searchQuery} - /> - {searchQuery && ( - - )} -
- - {searchQuery - ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` - : `${loaderData.populatedNodes.length} machines`} - -
-
- - - - - - {/* We only want to show the version column if there are agents */} - {loaderData.agent !== undefined ? ( - - ) : undefined} - - - - - {filteredAndSortedNodes.length === 0 ? ( - - - - ) : ( - filteredAndSortedNodes.map((node) => ( - - )) - )} - -
- - -
- - {loaderData.magic ? ( - - - - Since MagicDNS is enabled, you can access devices based - on their name and also at{' '} - - [name]. - {loaderData.magic} - - - - ) : undefined} -
-
- - - -
- No machines found matching "{searchQuery}" -
-
- - ); + return ( + <> +
+
+

Machines

+

+ Manage the devices connected to your Tailnet.{" "} + + Learn more + +

+
+ +
+
+
+ setSearchQuery(value.slice(0, 100))} + placeholder="Search by name or IP address..." + value={searchQuery} + /> + {searchQuery && ( + + )} +
+ + {searchQuery + ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` + : `${loaderData.populatedNodes.length} machines`} + +
+
+ + + + + + {/* We only want to show the version column if there are agents */} + {loaderData.agent !== undefined ? ( + + ) : undefined} + + + + + {filteredAndSortedNodes.length === 0 ? ( + + + + ) : ( + filteredAndSortedNodes.map((node) => ( + + )) + )} + +
+ + +
+ + {loaderData.magic ? ( + + + + Since MagicDNS is enabled, you can access devices based on their name and + also at{" "} + + [name]. + {loaderData.magic} + + + + ) : undefined} +
+
+ + + +
+ No machines found matching "{searchQuery}" +
+
+ + ); } diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx index ee5ff33..dbbedd6 100644 --- a/app/routes/users/onboarding.tsx +++ b/app/routes/users/onboarding.tsx @@ -1,328 +1,314 @@ -import { Icon } from '@iconify/react'; -import { ArrowRight } from 'lucide-react'; -import { useEffect } from 'react'; -import { NavLink } from 'react-router'; -import Button from '~/components/Button'; -import Card from '~/components/Card'; -import Link from '~/components/Link'; -import Options from '~/components/Options'; -import StatusCircle from '~/components/StatusCircle'; -import { Machine } from '~/types'; -import cn from '~/utils/cn'; -import { useLiveData } from '~/utils/live-data'; -import log from '~/utils/log'; -import toast from '~/utils/toast'; -import type { Route } from './+types/onboarding'; +import { Icon } from "@iconify/react"; +import { ArrowRight } from "lucide-react"; +import { useEffect } from "react"; +import { NavLink } from "react-router"; + +import Button from "~/components/Button"; +import Card from "~/components/Card"; +import Link from "~/components/Link"; +import Options from "~/components/Options"; +import StatusCircle from "~/components/StatusCircle"; +import { Machine } from "~/types"; +import cn from "~/utils/cn"; +import { useLiveData } from "~/utils/live-data"; +import log from "~/utils/log"; +import toast from "~/utils/toast"; + +import type { Route } from "./+types/onboarding"; export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); + const session = await context.sessions.auth(request); - // Try to determine the OS split between Linux, Windows, macOS, iOS, and Android - // We need to convert this to a known value to return it to the client so we can - // automatically tab to the correct download button. - const userAgent = request.headers.get('user-agent'); - const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); - let osValue = 'linux'; - switch (os?.[0]) { - case 'Windows': - osValue = 'windows'; - break; - case 'Mac OS X': - osValue = 'macos'; - break; + // Try to determine the OS split between Linux, Windows, macOS, iOS, and Android + // We need to convert this to a known value to return it to the client so we can + // automatically tab to the correct download button. + const userAgent = request.headers.get("user-agent"); + const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); + let osValue = "linux"; + switch (os?.[0]) { + case "Windows": + osValue = "windows"; + break; + case "Mac OS X": + osValue = "macos"; + break; - case 'iPhone': - case 'iPad': - osValue = 'ios'; - break; + case "iPhone": + case "iPad": + osValue = "ios"; + break; - case 'Android': - osValue = 'android'; - break; + case "Android": + osValue = "android"; + break; - default: - osValue = 'linux'; - break; - } + default: + osValue = "linux"; + break; + } - const api = context.hsApi.getRuntimeClient(session.api_key); - let firstMachine: Machine | undefined; - try { - const nodes = await api.getNodes(); - const node = nodes.find((n) => { - if (n.user.provider !== 'oidc') { - return false; - } + const api = context.hsApi.getRuntimeClient(session.api_key); + let firstMachine: Machine | undefined; + try { + const nodes = await api.getNodes(); + const node = nodes.find((n) => { + // Tag-only nodes have no user + if (!n.user || n.user.provider !== "oidc") { + return false; + } - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = n.user.providerId?.split('/').pop(); - if (!subject) { - return false; - } + // For some reason, headscale makes providerID a url where the + // last component is the subject, so we need to strip that out + const subject = n.user.providerId?.split("/").pop(); + if (!subject) { + return false; + } - if (subject !== session.user.subject) { - return false; - } + if (subject !== session.user.subject) { + return false; + } - return true; - }); + return true; + }); - firstMachine = node; - } catch (e) { - // If we cannot lookup nodes, we cannot proceed - log.debug('api', 'Failed to lookup nodes %o', e); - } + firstMachine = node; + } catch (e) { + // If we cannot lookup nodes, we cannot proceed + log.debug("api", "Failed to lookup nodes %o", e); + } - return { - user: session.user, - osValue, - firstMachine, - }; + return { + user: session.user, + osValue, + firstMachine, + }; } export default function Page({ - loaderData: { user, osValue, firstMachine }, + loaderData: { user, osValue, firstMachine }, }: Route.ComponentProps) { - const { pause, resume } = useLiveData(); - useEffect(() => { - if (firstMachine) { - pause(); - } else { - resume(); - } - }, [firstMachine]); + const { pause, resume } = useLiveData(); + useEffect(() => { + if (firstMachine) { + pause(); + } else { + resume(); + } + }, [firstMachine]); - const subject = user.email ? ( - <> - as {user.email} - - ) : ( - 'with your OIDC provider' - ); + const subject = user.email ? ( + <> + as {user.email} + + ) : ( + "with your OIDC provider" + ); - return ( -
-
- - - Welcome! -
- Let's get set up -
- - Install Tailscale and sign in {subject}. Once you sign in on a - device, it will be automatically added to your Headscale network. - + return ( +
+
+ + + Welcome! +
+ Let's get set up +
+ + Install Tailscale and sign in {subject}. Once you sign in on a device, it will be + automatically added to your Headscale network. + - - - - Linux -
- } - > -
+ } + > + -

- Click this button to copy the command.{' '} - - View script source - -

- - - - Windows -
- } - > - - - -

- Requires Windows 10 or later. -

- - - - macOS -
- } - > - - - -

- Requires macOS Big Sur 11.0 or later. -
- You can also download Tailscale on the{' '} - - macOS App Store - - {'.'} -

- - - - iOS -
- } - > - - - -

- Requires iOS 15 or later. -

- - - - Android -
- } - > - - - -

- Requires Android 8 or later. -

- - - - - {firstMachine ? ( -
- - Success! -
- We found your first device -
-
-
- -
-

- {firstMachine.givenName} -

-

- {firstMachine.name} -

-
-

IP Addresses

- {firstMachine.ipAddresses.map((ip) => ( -

- {ip} -

- ))} -
-
-
-
- - - -
- ) : ( -
- - - - -

Waiting for your first device...

-
- )} -
- - - - - - ); + toast("Copied to clipboard"); + }} + > + curl -fsSL https://tailscale.com/install.sh | sh + +

+ Click this button to copy the command.{" "} + + View script source + +

+ + + + Windows + + } + > + + + +

+ Requires Windows 10 or later. +

+
+ + + macOS + + } + > + + + +

+ Requires macOS Big Sur 11.0 or later. +
+ You can also download Tailscale on the{" "} + + macOS App Store + + {"."} +

+
+ + + iOS + + } + > + + + +

+ Requires iOS 15 or later. +

+
+ + + Android + + } + > + + + +

+ Requires Android 8 or later. +

+
+ + + + {firstMachine ? ( +
+ + Success! +
+ We found your first device +
+
+
+ +
+

{firstMachine.givenName}

+

{firstMachine.name}

+
+

IP Addresses

+ {firstMachine.ipAddresses.map((ip) => ( +

+ {ip} +

+ ))} +
+
+
+
+ + + +
+ ) : ( +
+ + + + +

Waiting for your first device...

+
+ )} +
+ + + + + + ); } diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx index a7057b6..eb14eb0 100644 --- a/app/routes/users/overview.tsx +++ b/app/routes/users/overview.tsx @@ -1,147 +1,142 @@ -import { createHash } from 'node:crypto'; -import { useEffect, useState } from 'react'; -import { Capabilities } from '~/server/web/roles'; -import type { Machine, User } from '~/types'; -import cn from '~/utils/cn'; -import type { Route } from './+types/overview'; -import ManageBanner from './components/manage-banner'; -import UserRow from './components/user-row'; -import { userAction } from './user-actions'; +import { createHash } from "node:crypto"; +import { useEffect, useState } from "react"; + +import type { Machine, User } from "~/types"; + +import { Capabilities } from "~/server/web/roles"; +import cn from "~/utils/cn"; + +import type { Route } from "./+types/overview"; + +import ManageBanner from "./components/manage-banner"; +import UserRow from "./components/user-row"; +import { userAction } from "./user-actions"; interface UserMachine extends User { - machines: Machine[]; + machines: Machine[]; } export async function loader({ request, context }: Route.LoaderArgs) { - const session = await context.sessions.auth(request); - const check = await context.sessions.check(request, Capabilities.read_users); - if (!check) { - // Not authorized to view this page - throw new Error( - 'You do not have permission to view this page. Please contact your administrator.', - ); - } + const session = await context.sessions.auth(request); + const check = await context.sessions.check(request, Capabilities.read_users); + if (!check) { + // Not authorized to view this page + throw new Error( + "You do not have permission to view this page. Please contact your administrator.", + ); + } - const writablePermission = await context.sessions.check( - request, - Capabilities.write_users, - ); + const writablePermission = await context.sessions.check(request, Capabilities.write_users); - const api = context.hsApi.getRuntimeClient(session.api_key); - const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); + const api = context.hsApi.getRuntimeClient(session.api_key); + const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); - const users = apiUsers.map((user) => ({ - ...user, - machines: nodes.filter((node) => node.user.id === user.id), - profilePicUrl: - context.config.oidc?.profile_picture_source === 'gravatar' - ? (() => { - if (!user.email) { - return undefined; - } + const users = apiUsers.map((user) => ({ + ...user, + machines: nodes.filter((node) => node.user?.id === user.id), + profilePicUrl: + context.config.oidc?.profile_picture_source === "gravatar" + ? (() => { + if (!user.email) { + return undefined; + } - const emailHash = user.email.trim().toLowerCase(); - const hash = createHash('sha256').update(emailHash).digest('hex'); - return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; - })() - : user.profilePicUrl, - })); + const emailHash = user.email.trim().toLowerCase(); + const hash = createHash("sha256").update(emailHash).digest("hex"); + return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; + })() + : user.profilePicUrl, + })); - const roles = await Promise.all( - users - .sort((a, b) => a.name.localeCompare(b.name)) - .map(async (user) => { - if (user.provider !== 'oidc') { - return 'no-oidc'; - } + const roles = await Promise.all( + users + .sort((a, b) => a.name.localeCompare(b.name)) + .map(async (user) => { + if (user.provider !== "oidc") { + return "no-oidc"; + } - if (user.provider === 'oidc' && user.providerId) { - // For some reason, headscale makes providerID a url where the - // last component is the subject, so we need to strip that out - const subject = user.providerId.split('/').pop(); - if (!subject) { - return 'invalid-oidc'; - } + if (user.provider === "oidc" && user.providerId) { + // For some reason, headscale makes providerID a url where the + // last component is the subject, so we need to strip that out + const subject = user.providerId.split("/").pop(); + if (!subject) { + return "invalid-oidc"; + } - const role = await context.sessions.roleForSubject(subject); - return role ?? 'no-role'; - } + const role = await context.sessions.roleForSubject(subject); + return role ?? "no-role"; + } - // No role means the user is not registered in Headplane, but they - // are in Headscale. We also need to handle what happens if someone - // logs into the UI and they don't have a Headscale setup. - return 'no-role'; - }), - ); + // No role means the user is not registered in Headplane, but they + // are in Headscale. We also need to handle what happens if someone + // logs into the UI and they don't have a Headscale setup. + return "no-role"; + }), + ); - let magic: string | undefined; - if (context.hs.readable()) { - if (context.hs.c?.dns.magic_dns) { - magic = context.hs.c.dns.base_domain; - } - } + let magic: string | undefined; + if (context.hs.readable()) { + if (context.hs.c?.dns.magic_dns) { + magic = context.hs.c.dns.base_domain; + } + } - return { - writable: writablePermission, // whether the user can write to the API - oidc: context.config.oidc - ? { - issuer: context.config.oidc.issuer, - } - : undefined, - roles, - magic, - users, - }; + return { + writable: writablePermission, // whether the user can write to the API + oidc: context.config.oidc + ? { + issuer: context.config.oidc.issuer, + } + : undefined, + roles, + magic, + users, + }; } export const action = userAction; export default function Page({ loaderData }: Route.ComponentProps) { - const [users, setUsers] = useState(loaderData.users); + const [users, setUsers] = useState(loaderData.users); - // This useEffect is entirely for the purpose of updating the users when the - // drag and drop changes the machines between users. It's pretty hacky, but - // the idea is to treat data.users as the source of truth and update the - // local state when it changes. - useEffect(() => { - setUsers(loaderData.users); - }, [loaderData.users]); + // This useEffect is entirely for the purpose of updating the users when the + // drag and drop changes the machines between users. It's pretty hacky, but + // the idea is to treat data.users as the source of truth and update the + // local state when it changes. + useEffect(() => { + setUsers(loaderData.users); + }, [loaderData.users]); - return ( - <> -

Users

-

- Manage the users in your network and their permissions. -

- -
- - - - - - - - - - - {users - .sort((a, b) => a.name.localeCompare(b.name)) - .map((user) => ( - - ))} - -
UserRoleCreated AtLast Seen
-
- - ); + return ( + <> +

Users

+

Manage the users in your network and their permissions.

+ +
+ + + + + + + + + + + {users + .sort((a, b) => a.name.localeCompare(b.name)) + .map((user) => ( + + ))} + +
UserRoleCreated AtLast Seen
+
+ + ); } diff --git a/app/types/Machine.ts b/app/types/Machine.ts index c644310..8391583 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -9,7 +9,8 @@ export interface Machine { ipAddresses: string[]; name: string; - user: User; + // User can be null for tag-only nodes in Headscale 0.28+ + user?: User; lastSeen: string; expiry: string | null;