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;