Merge pull request #467 from drifterza/feature/tag-node-operations

This commit is contained in:
Aarnav Tale
2026-02-25 20:34:49 -05:00
committed by GitHub
8 changed files with 822 additions and 843 deletions
@@ -63,7 +63,9 @@ export default function MachineRow({
{node.givenName} {node.givenName}
</p> </p>
<p className="text-sm opacity-50"> <p className="text-sm opacity-50">
{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"}
</p> </p>
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)} {mapTagsToComponents(node, uiTags)}
+38 -38
View File
@@ -1,45 +1,45 @@
import { Key, useState } from 'react'; import { Key, useState } from "react";
import Dialog from '~/components/Dialog';
import Select from '~/components/Select'; import type { Machine, User } from "~/types";
import type { Machine, User } from '~/types';
import Dialog from "~/components/Dialog";
import Select from "~/components/Select";
interface MoveProps { interface MoveProps {
machine: Machine; machine: Machine;
users: User[]; users: User[];
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
} }
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) { export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(machine.user.id); const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user.id}> <Dialog.Panel isDisabled={userId === machine.user?.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title> <Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
The owner of the machine is the user associated with it. <input name="action_id" type="hidden" value="reassign" />
</Dialog.Text> <input name="node_id" type="hidden" value={machine.id} />
<input name="action_id" type="hidden" value="reassign" /> <input name="user_id" type="hidden" value={userId?.toString()} />
<input name="node_id" type="hidden" value={machine.id} /> <Select
<input name="user_id" type="hidden" value={userId?.toString()} /> defaultSelectedKey={machine.user?.id}
<Select isRequired
defaultSelectedKey={machine.user.id} label="Owner"
isRequired name="user"
label="Owner" onSelectionChange={(key) => {
name="user" setUserId(key);
onSelectionChange={(key) => { }}
setUserId(key); placeholder="Select a user"
}} >
placeholder="Select a user" {users.map((user) => (
> <Select.Item key={user.id}>
{users.map((user) => ( {user.name || user.displayName || user.email || user.id}
<Select.Item key={user.id}> </Select.Item>
{user.name || user.displayName || user.email || user.id} ))}
</Select.Item> </Select>
))} </Dialog.Panel>
</Select> </Dialog>
</Dialog.Panel> );
</Dialog>
);
} }
+3 -1
View File
@@ -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", { throw data("You do not have permission to act on this machine", {
status: 403, status: 403,
}); });
+9 -3
View File
@@ -44,7 +44,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const lookup = await context.agents?.lookup([node.nodeKey]); const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup); const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort(); 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 { return {
node: enhancedNode, node: enhancedNode,
@@ -109,7 +109,9 @@ export default function Page({
</span> </span>
<div className="mt-1 flex items-center gap-x-2.5"> <div className="mt-1 flex items-center gap-x-2.5">
<UserCircle /> <UserCircle />
{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"}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> <div className="p-2 pl-4">
@@ -241,7 +243,11 @@ export default function Page({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Attribute <Attribute
name="Creator" name="Creator"
value={node.user.name || node.user.displayName || node.user.email || node.user.id} value={
node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"
}
/> />
<Attribute name="Machine name" value={node.givenName} /> <Attribute name="Machine name" value={node.givenName} />
<Attribute <Attribute
+355 -368
View File
@@ -1,394 +1,381 @@
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react'; import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from "react";
import Code from '~/components/Code';
import Input from '~/components/Input'; import Code from "~/components/Code";
import Link from '~/components/Link'; import Input from "~/components/Input";
import Tooltip from '~/components/Tooltip'; import Link from "~/components/Link";
import { Capabilities } from '~/server/web/roles'; import Tooltip from "~/components/Tooltip";
import cn from '~/utils/cn'; import { Capabilities } from "~/server/web/roles";
import { mapNodes, sortNodeTags } from '~/utils/node-info'; import cn from "~/utils/cn";
import type { Route } from './+types/overview'; import { mapNodes, sortNodeTags } from "~/utils/node-info";
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new'; import type { Route } from "./+types/overview";
import { machineAction } from './machine-actions';
import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.user; const user = session.user;
if (!user) { if (!user) {
throw new Error('Missing user session. Please log in again.'); throw new Error("Missing user session. Please log in again.");
} }
const check = await context.sessions.check( const check = await context.sessions.check(request, Capabilities.read_machines);
request,
Capabilities.read_machines,
);
if (!check) { if (!check) {
// Not authorized to view this page // Not authorized to view this page
throw new Error( throw new Error(
'You do not have permission to view this page. Please contact your administrator.', "You do not have permission to view this page. Please contact your administrator.",
); );
} }
const writablePermission = await context.sessions.check( const writablePermission = await context.sessions.check(request, Capabilities.write_machines);
request,
Capabilities.write_machines,
);
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) { if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = context.hs.c.dns.base_domain;
} }
} }
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey)); const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats); const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1"); const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return { return {
populatedNodes, populatedNodes,
nodes, nodes,
users, users,
magic, magic,
server: context.config.headscale.url, server: context.config.headscale.url,
publicServer: context.config.headscale.public_url, publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
writable: writablePermission, writable: writablePermission,
preAuth: await context.sessions.check( preAuth: await context.sessions.check(request, Capabilities.generate_authkeys),
request, subject: user.subject,
Capabilities.generate_authkeys, supportsNodeOwnerChange: supportsNodeOwnerChange,
), };
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
} }
export const action = machineAction; export const action = machineAction;
type SortField = 'name' | 'ip' | 'version' | 'lastSeen'; type SortField = "name" | "ip" | "version" | "lastSeen";
export default function Page({ loaderData }: Route.ComponentProps) { export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>('name'); const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const filteredAndSortedNodes = useMemo(() => { const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim(); const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => { let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true; if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true; if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
return true; return false;
return false; });
});
nodes = [...nodes].sort((a, b) => { nodes = [...nodes].sort((a, b) => {
let comparison = 0; let comparison = 0;
switch (sortField) { switch (sortField) {
case 'name': case "name":
comparison = a.givenName.localeCompare(b.givenName); comparison = a.givenName.localeCompare(b.givenName);
break; break;
case 'ip': { case "ip": {
const getIPv4 = (addresses: string[]) => const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(':')) || addresses[0] || ''; addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
const ipA = getIPv4(a.ipAddresses); const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses); const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(':') && !ipB.includes(':')) { if (!ipA.includes(":") && !ipB.includes(":")) {
const octetsA = ipA.split('.').map(Number); const octetsA = ipA.split(".").map(Number);
const octetsB = ipB.split('.').map(Number); const octetsB = ipB.split(".").map(Number);
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) { if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i]; comparison = octetsA[i] - octetsB[i];
break; break;
} }
} }
} else { } else {
comparison = ipA.localeCompare(ipB); comparison = ipA.localeCompare(ipB);
} }
break; break;
} }
case 'version': { case "version": {
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0'; const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0'; const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
const partsA = versionA.split('.').map(Number); const partsA = versionA.split(".").map(Number);
const partsB = versionB.split('.').map(Number); const partsB = versionB.split(".").map(Number);
const maxLen = Math.max(partsA.length, partsB.length); const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) { for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0; const segA = partsA[i] || 0;
const segB = partsB[i] || 0; const segB = partsB[i] || 0;
if (segA !== segB) { if (segA !== segB) {
comparison = segA - segB; comparison = segA - segB;
break; break;
} }
} }
break; break;
} }
case 'lastSeen': case "lastSeen":
if (a.online !== b.online) { if (a.online !== b.online) {
comparison = a.online ? 1 : -1; comparison = a.online ? 1 : -1;
break; break;
} }
comparison = comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime(); break;
break; }
}
return sortDirection === 'asc' ? comparison : -comparison; return sortDirection === "asc" ? comparison : -comparison;
}); });
return nodes; return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]); }, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => { const handleSort = (field: SortField) => {
if (sortField === field) { if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else { } else {
setSortField(field); setSortField(field);
setSortDirection('asc'); setSortDirection("asc");
} }
}; };
return ( return (
<> <>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1> <h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p> <p>
Manage the devices connected to your Tailnet.{' '} Manage the devices connected to your Tailnet.{" "}
<Link <Link
name="Tailscale Manage Devices Documentation" name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices" to="https://tailscale.com/kb/1372/manage-devices"
> >
Learn more Learn more
</Link> </Link>
</p> </p>
</div> </div>
<NewMachine <NewMachine
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']} disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
isDisabled={!loaderData.writable} isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server} server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users} users={loaderData.users}
/> />
</div> </div>
<div className="mb-4 flex items-center gap-4"> <div className="mb-4 flex items-center gap-4">
<div className="relative w-64"> <div className="relative w-64">
<Input <Input
label="Search machines" label="Search machines"
labelHidden labelHidden
maxLength={100} maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))} onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..." placeholder="Search by name or IP address..."
value={searchQuery} value={searchQuery}
/> />
{searchQuery && ( {searchQuery && (
<button <button
aria-label="Clear search" aria-label="Clear search"
className={cn( className={cn(
'absolute right-2 top-1/2 -translate-y-1/2', "absolute right-2 top-1/2 -translate-y-1/2",
'p-1 rounded-full', "p-1 rounded-full",
'text-headplane-400 hover:text-headplane-600', "text-headplane-400 hover:text-headplane-600",
'dark:text-headplane-500 dark:hover:text-headplane-300', "dark:text-headplane-500 dark:hover:text-headplane-300",
'hover:bg-headplane-100 dark:hover:bg-headplane-800', "hover:bg-headplane-100 dark:hover:bg-headplane-800",
)} )}
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery("")}
type="button" type="button"
> >
<X className="w-4 h-4" /> <X className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
<span className="text-sm text-headplane-500 whitespace-nowrap"> <span className="text-headplane-500 text-sm whitespace-nowrap">
{searchQuery {searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`} : `${loaderData.populatedNodes.length} machines`}
</span> </span>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]"> <table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300"> <thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5"> <tr className="px-0.5 text-left">
<th <th
aria-sort={ aria-sort={
sortField === 'name' sortField === "name"
? sortDirection === 'asc' ? sortDirection === "asc"
? 'ascending' ? "ascending"
: 'descending' : "descending"
: 'none' : "none"
} }
className="uppercase text-xs font-bold pb-2" className="pb-2 text-xs font-bold uppercase"
> >
<button <button
aria-label="Sort by name" aria-label="Sort by name"
className={cn( className={cn(
'flex items-center gap-x-1 cursor-pointer', "flex items-center gap-x-1 cursor-pointer",
'hover:text-headplane-900 dark:hover:text-headplane-100', "hover:text-headplane-900 dark:hover:text-headplane-100",
)} )}
onClick={() => handleSort('name')} onClick={() => handleSort("name")}
type="button" type="button"
> >
Name Name
{sortField === 'name' && {sortField === "name" &&
(sortDirection === 'asc' ? ( (sortDirection === "asc" ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="h-3 w-3" />
))} ))}
</button> </button>
</th> </th>
<th <th
aria-sort={ aria-sort={
sortField === 'ip' sortField === "ip"
? sortDirection === 'asc' ? sortDirection === "asc"
? 'ascending' ? "ascending"
: 'descending' : "descending"
: 'none' : "none"
} }
className="pb-2 w-1/4" className="w-1/4 pb-2"
> >
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<button <button
aria-label="Sort by IP address" aria-label="Sort by IP address"
className={cn( className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold', "flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
'hover:text-headplane-900 dark:hover:text-headplane-100', "hover:text-headplane-900 dark:hover:text-headplane-100",
)} )}
onClick={() => handleSort('ip')} onClick={() => handleSort("ip")}
type="button" type="button"
> >
Addresses Addresses
{sortField === 'ip' && {sortField === "ip" &&
(sortDirection === 'asc' ? ( (sortDirection === "asc" ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="h-3 w-3" />
))} ))}
</button> </button>
{loaderData.magic ? ( {loaderData.magic ? (
<Tooltip> <Tooltip>
<Info className="w-4 h-4" /> <Info className="h-4 w-4" />
<Tooltip.Body className="font-normal"> <Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based Since MagicDNS is enabled, you can access devices based on their name and
on their name and also at{' '} also at{" "}
<Code> <Code>
[name]. [name].
{loaderData.magic} {loaderData.magic}
</Code> </Code>
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
) : undefined} ) : undefined}
</div> </div>
</th> </th>
{/* We only want to show the version column if there are agents */} {/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? ( {loaderData.agent !== undefined ? (
<th <th
aria-sort={ aria-sort={
sortField === 'version' sortField === "version"
? sortDirection === 'asc' ? sortDirection === "asc"
? 'ascending' ? "ascending"
: 'descending' : "descending"
: 'none' : "none"
} }
className="uppercase text-xs font-bold pb-2" className="pb-2 text-xs font-bold uppercase"
> >
<button <button
aria-label="Sort by version" aria-label="Sort by version"
className={cn( className={cn(
'flex items-center gap-x-1 cursor-pointer', "flex items-center gap-x-1 cursor-pointer",
'hover:text-headplane-900 dark:hover:text-headplane-100', "hover:text-headplane-900 dark:hover:text-headplane-100",
)} )}
onClick={() => handleSort('version')} onClick={() => handleSort("version")}
type="button" type="button"
> >
Version Version
{sortField === 'version' && {sortField === "version" &&
(sortDirection === 'asc' ? ( (sortDirection === "asc" ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="h-3 w-3" />
))} ))}
</button> </button>
</th> </th>
) : undefined} ) : undefined}
<th <th
aria-sort={ aria-sort={
sortField === 'lastSeen' sortField === "lastSeen"
? sortDirection === 'asc' ? sortDirection === "asc"
? 'ascending' ? "ascending"
: 'descending' : "descending"
: 'none' : "none"
} }
className="uppercase text-xs font-bold pb-2" className="pb-2 text-xs font-bold uppercase"
> >
<button <button
aria-label="Sort by last seen" aria-label="Sort by last seen"
className={cn( className={cn(
'flex items-center gap-x-1 cursor-pointer', "flex items-center gap-x-1 cursor-pointer",
'hover:text-headplane-900 dark:hover:text-headplane-100', "hover:text-headplane-900 dark:hover:text-headplane-100",
)} )}
onClick={() => handleSort('lastSeen')} onClick={() => handleSort("lastSeen")}
type="button" type="button"
> >
Last Seen Last Seen
{sortField === 'lastSeen' && {sortField === "lastSeen" &&
(sortDirection === 'asc' ? ( (sortDirection === "asc" ? (
<ChevronUp className="w-3 h-3" /> <ChevronUp className="h-3 w-3" />
) : ( ) : (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="h-3 w-3" />
))} ))}
</button> </button>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody <tbody
className={cn( className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top', "divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
'border-t border-headplane-100 dark:border-headplane-800', "border-t border-headplane-100 dark:border-headplane-800",
)} )}
> >
{filteredAndSortedNodes.length === 0 ? ( {filteredAndSortedNodes.length === 0 ? (
<tr> <tr>
<td <td
className="py-8 text-center text-headplane-500" className="text-headplane-500 py-8 text-center"
colSpan={loaderData.agent !== undefined ? 5 : 4} colSpan={loaderData.agent !== undefined ? 5 : 4}
> >
No machines found matching "{searchQuery}" No machines found matching "{searchQuery}"
</td> </td>
</tr> </tr>
) : ( ) : (
filteredAndSortedNodes.map((node) => ( filteredAndSortedNodes.map((node) => (
<MachineRow <MachineRow
existingTags={sortNodeTags(loaderData.nodes)} existingTags={sortNodeTags(loaderData.nodes)}
isAgent={ isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
loaderData.agent isDisabled={
? loaderData.agent === node.nodeKey loaderData.writable
: undefined ? false // If the user has write permissions, they can edit all machines
} : node.user?.providerId?.split("/").pop() !== loaderData.subject
isDisabled={ }
loaderData.writable key={node.id}
? false // If the user has write permissions, they can edit all machines magic={loaderData.magic}
: node.user.providerId?.split('/').pop() !== node={node}
loaderData.subject users={loaderData.users}
} supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
key={node.id} />
magic={loaderData.magic} ))
node={node} )}
users={loaderData.users} </tbody>
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange} </table>
/> </div>
)) </>
)} );
</tbody>
</table>
</div>
</>
);
} }
+293 -307
View File
@@ -1,328 +1,314 @@
import { Icon } from '@iconify/react'; import { Icon } from "@iconify/react";
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from "lucide-react";
import { useEffect } from 'react'; import { useEffect } from "react";
import { NavLink } from 'react-router'; import { NavLink } from "react-router";
import Button from '~/components/Button';
import Card from '~/components/Card'; import Button from "~/components/Button";
import Link from '~/components/Link'; import Card from "~/components/Card";
import Options from '~/components/Options'; import Link from "~/components/Link";
import StatusCircle from '~/components/StatusCircle'; import Options from "~/components/Options";
import { Machine } from '~/types'; import StatusCircle from "~/components/StatusCircle";
import cn from '~/utils/cn'; import { Machine } from "~/types";
import { useLiveData } from '~/utils/live-data'; import cn from "~/utils/cn";
import log from '~/utils/log'; import { useLiveData } from "~/utils/live-data";
import toast from '~/utils/toast'; import log from "~/utils/log";
import type { Route } from './+types/onboarding'; import toast from "~/utils/toast";
import type { Route } from "./+types/onboarding";
export async function loader({ request, context }: Route.LoaderArgs) { 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 // 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 // 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. // automatically tab to the correct download button.
const userAgent = request.headers.get('user-agent'); const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/); const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = 'linux'; let osValue = "linux";
switch (os?.[0]) { switch (os?.[0]) {
case 'Windows': case "Windows":
osValue = 'windows'; osValue = "windows";
break; break;
case 'Mac OS X': case "Mac OS X":
osValue = 'macos'; osValue = "macos";
break; break;
case 'iPhone': case "iPhone":
case 'iPad': case "iPad":
osValue = 'ios'; osValue = "ios";
break; break;
case 'Android': case "Android":
osValue = 'android'; osValue = "android";
break; break;
default: default:
osValue = 'linux'; osValue = "linux";
break; break;
} }
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined; let firstMachine: Machine | undefined;
try { try {
const nodes = await api.getNodes(); const nodes = await api.getNodes();
const node = nodes.find((n) => { const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') { // Tag-only nodes have no user
return false; if (!n.user || n.user.provider !== "oidc") {
} return false;
}
// For some reason, headscale makes providerID a url where the // For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out // last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split('/').pop(); const subject = n.user.providerId?.split("/").pop();
if (!subject) { if (!subject) {
return false; return false;
} }
if (subject !== session.user.subject) { if (subject !== session.user.subject) {
return false; return false;
} }
return true; return true;
}); });
firstMachine = node; firstMachine = node;
} catch (e) { } catch (e) {
// If we cannot lookup nodes, we cannot proceed // If we cannot lookup nodes, we cannot proceed
log.debug('api', 'Failed to lookup nodes %o', e); log.debug("api", "Failed to lookup nodes %o", e);
} }
return { return {
user: session.user, user: session.user,
osValue, osValue,
firstMachine, firstMachine,
}; };
} }
export default function Page({ export default function Page({
loaderData: { user, osValue, firstMachine }, loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) { }: Route.ComponentProps) {
const { pause, resume } = useLiveData(); const { pause, resume } = useLiveData();
useEffect(() => { useEffect(() => {
if (firstMachine) { if (firstMachine) {
pause(); pause();
} else { } else {
resume(); resume();
} }
}, [firstMachine]); }, [firstMachine]);
const subject = user.email ? ( const subject = user.email ? (
<> <>
as <strong>{user.email}</strong> as <strong>{user.email}</strong>
</> </>
) : ( ) : (
'with your OIDC provider' "with your OIDC provider"
); );
return ( return (
<div className="fixed w-full h-screen flex items-center px-4"> <div className="fixed flex h-screen w-full items-center px-4">
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24"> <div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
<Card className="max-w-lg" variant="flat"> <Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8"> <Card.Title className="mb-8">
Welcome! Welcome!
<br /> <br />
Let's get set up Let's get set up
</Card.Title> </Card.Title>
<Card.Text> <Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
device, it will be automatically added to your Headscale network. automatically added to your Headscale network.
</Card.Text> </Card.Text>
<Options <Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
className="my-4" <Options.Item
defaultSelectedKey={osValue} key="linux"
label="Download Selector" title={
> <div className="flex items-center gap-1">
<Options.Item <Icon className="ml-1 w-4" icon="ion:terminal" />
key="linux" <span>Linux</span>
title={ </div>
<div className="flex items-center gap-1"> }
<Icon className="ml-1 w-4" icon="ion:terminal" /> >
<span>Linux</span> <Button
</div> className="text-md flex font-mono"
} onPress={async () => {
> await navigator.clipboard.writeText(
<Button "curl -fsSL https://tailscale.com/install.sh | sh",
className="flex text-md font-mono" );
onPress={async () => {
await navigator.clipboard.writeText(
'curl -fsSL https://tailscale.com/install.sh | sh',
);
toast('Copied to clipboard'); toast("Copied to clipboard");
}} }}
> >
curl -fsSL https://tailscale.com/install.sh | sh curl -fsSL https://tailscale.com/install.sh | sh
</Button> </Button>
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center"> <p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
Click this button to copy the command.{' '} Click this button to copy the command.{" "}
<Link <Link
name="Linux installation script" name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh" to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
> >
View script source View script source
</Link> </Link>
</p> </p>
</Options.Item> </Options.Item>
<Options.Item <Options.Item
key="windows" key="windows"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" /> <Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span> <span>Windows</span>
</div> </div>
} }
> >
<a <a
aria-label="Download for Windows" aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe" href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer" rel="noreferrer"
target="_blank" target="_blank"
> >
<Button className="my-4 w-full" variant="heavy"> <Button className="my-4 w-full" variant="heavy">
Download for Windows Download for Windows
</Button> </Button>
</a> </a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center"> <p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Windows 10 or later. Requires Windows 10 or later.
</p> </p>
</Options.Item> </Options.Item>
<Options.Item <Options.Item
key="macos" key="macos"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Icon <Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
className="ml-1 w-4" <span>macOS</span>
icon="streamline-logos:mac-finder-logo-solid" </div>
/> }
<span>macOS</span> >
</div> <a
} aria-label="Download for macOS"
> href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
<a rel="noreferrer"
aria-label="Download for macOS" target="_blank"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg" >
rel="noreferrer" <Button className="my-4 w-full" variant="heavy">
target="_blank" Download for macOS
> </Button>
<Button className="my-4 w-full" variant="heavy"> </a>
Download for macOS <p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
</Button> Requires macOS Big Sur 11.0 or later.
</a> <br />
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center"> You can also download Tailscale on the{" "}
Requires macOS Big Sur 11.0 or later. <Link
<br /> name="macOS App Store"
You can also download Tailscale on the{' '} to="https://apps.apple.com/ca/app/tailscale/id1475387142"
<Link >
name="macOS App Store" macOS App Store
to="https://apps.apple.com/ca/app/tailscale/id1475387142" </Link>
> {"."}
macOS App Store </p>
</Link> </Options.Item>
{'.'} <Options.Item
</p> key="ios"
</Options.Item> title={
<Options.Item <div className="flex items-center gap-1">
key="ios" <Icon className="ml-1 w-4" icon="grommet-icons:apple" />
title={ <span>iOS</span>
<div className="flex items-center gap-1"> </div>
<Icon className="ml-1 w-4" icon="grommet-icons:apple" /> }
<span>iOS</span> >
</div> <a
} aria-label="Download for iOS"
> href="https://apps.apple.com/us/app/tailscale/id1470499037"
<a rel="noreferrer"
aria-label="Download for iOS" target="_blank"
href="https://apps.apple.com/us/app/tailscale/id1470499037" >
rel="noreferrer" <Button className="my-4 w-full" variant="heavy">
target="_blank" Download for iOS
> </Button>
<Button className="my-4 w-full" variant="heavy"> </a>
Download for iOS <p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
</Button> Requires iOS 15 or later.
</a> </p>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center"> </Options.Item>
Requires iOS 15 or later. <Options.Item
</p> key="android"
</Options.Item> title={
<Options.Item <div className="flex items-center gap-1">
key="android" <Icon className="ml-1 w-4" icon="material-symbols:android" />
title={ <span>Android</span>
<div className="flex items-center gap-1"> </div>
<Icon className="ml-1 w-4" icon="material-symbols:android" /> }
<span>Android</span> >
</div> <a
} aria-label="Download for Android"
> href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
<a rel="noreferrer"
aria-label="Download for Android" target="_blank"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn" >
rel="noreferrer" <Button className="my-4 w-full" variant="heavy">
target="_blank" Download for Android
> </Button>
<Button className="my-4 w-full" variant="heavy"> </a>
Download for Android <p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
</Button> Requires Android 8 or later.
</a> </p>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center"> </Options.Item>
Requires Android 8 or later. </Options>
</p> </Card>
</Options.Item> <Card variant="flat">
</Options> {firstMachine ? (
</Card> <div className="flex h-full flex-col justify-between">
<Card variant="flat"> <Card.Title className="mb-8">
{firstMachine ? ( Success!
<div className="flex flex-col justify-between h-full"> <br />
<Card.Title className="mb-8"> We found your first device
Success! </Card.Title>
<br /> <div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
We found your first device <div className="flex items-start gap-4">
</Card.Title> <StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4"> <div>
<div className="flex items-start gap-4"> <p className="leading-snug font-semibold">{firstMachine.givenName}</p>
<StatusCircle <p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
className="size-6 mt-3" <div className="mt-6">
isOnline={firstMachine.online} <p className="text-sm font-semibold">IP Addresses</p>
/> {firstMachine.ipAddresses.map((ip) => (
<div> <p className="font-mono text-xs opacity-50" key={ip}>
<p className="font-semibold leading-snug"> {ip}
{firstMachine.givenName} </p>
</p> ))}
<p className="text-sm font-mono opacity-50"> </div>
{firstMachine.name} </div>
</p> </div>
<div className="mt-6"> </div>
<p className="text-sm font-semibold">IP Addresses</p> <NavLink to="/onboarding/skip">
{firstMachine.ipAddresses.map((ip) => ( <Button className="w-full" variant="heavy">
<p className="text-xs font-mono opacity-50" key={ip}> Continue
{ip} </Button>
</p> </NavLink>
))} </div>
</div> ) : (
</div> <div className="flex h-full flex-col items-center justify-center gap-4">
</div> <span className="relative flex size-4">
</div> <span
<NavLink to="/onboarding/skip"> className={cn(
<Button className="w-full" variant="heavy"> "absolute inline-flex h-full w-full",
Continue "rounded-full opacity-75 animate-ping",
</Button> "bg-headplane-500",
</NavLink> )}
</div> />
) : ( <span
<div className="flex flex-col items-center justify-center gap-4 h-full"> className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
<span className="relative flex size-4"> />
<span </span>
className={cn( <p className="font-lg">Waiting for your first device...</p>
'absolute inline-flex h-full w-full', </div>
'rounded-full opacity-75 animate-ping', )}
'bg-headplane-500', </Card>
)} <NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
/> <Button className="flex items-center gap-1">
<span I already know what I'm doing
className={cn( <ArrowRight className="p-1" />
'relative inline-flex size-4 rounded-full', </Button>
'bg-headplane-400', </NavLink>
)} </div>
/> </div>
</span> );
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
} }
+119 -124
View File
@@ -1,147 +1,142 @@
import { createHash } from 'node:crypto'; import { createHash } from "node:crypto";
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types'; import type { Machine, User } from "~/types";
import cn from '~/utils/cn';
import type { Route } from './+types/overview'; import { Capabilities } from "~/server/web/roles";
import ManageBanner from './components/manage-banner'; import cn from "~/utils/cn";
import UserRow from './components/user-row';
import { userAction } from './user-actions'; 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 { interface UserMachine extends User {
machines: Machine[]; machines: Machine[];
} }
export async function loader({ request, context }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users); const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) { if (!check) {
// Not authorized to view this page // Not authorized to view this page
throw new Error( throw new Error(
'You do not have permission to view this page. Please contact your administrator.', "You do not have permission to view this page. Please contact your administrator.",
); );
} }
const writablePermission = await context.sessions.check( const writablePermission = await context.sessions.check(request, Capabilities.write_users);
request,
Capabilities.write_users,
);
const api = context.hsApi.getRuntimeClient(session.api_key); const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.map((user) => ({ const users = apiUsers.map((user) => ({
...user, ...user,
machines: nodes.filter((node) => node.user.id === user.id), machines: nodes.filter((node) => node.user?.id === user.id),
profilePicUrl: profilePicUrl:
context.config.oidc?.profile_picture_source === 'gravatar' context.config.oidc?.profile_picture_source === "gravatar"
? (() => { ? (() => {
if (!user.email) { if (!user.email) {
return undefined; return undefined;
} }
const emailHash = user.email.trim().toLowerCase(); const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex'); const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})() })()
: user.profilePicUrl, : user.profilePicUrl,
})); }));
const roles = await Promise.all( const roles = await Promise.all(
users users
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => { .map(async (user) => {
if (user.provider !== 'oidc') { if (user.provider !== "oidc") {
return 'no-oidc'; return "no-oidc";
} }
if (user.provider === 'oidc' && user.providerId) { if (user.provider === "oidc" && user.providerId) {
// For some reason, headscale makes providerID a url where the // For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out // last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop(); const subject = user.providerId.split("/").pop();
if (!subject) { if (!subject) {
return 'invalid-oidc'; return "invalid-oidc";
} }
const role = await context.sessions.roleForSubject(subject); const role = await context.sessions.roleForSubject(subject);
return role ?? 'no-role'; return role ?? "no-role";
} }
// No role means the user is not registered in Headplane, but they // No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone // are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup. // logs into the UI and they don't have a Headscale setup.
return 'no-role'; return "no-role";
}), }),
); );
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) { if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain; magic = context.hs.c.dns.base_domain;
} }
} }
return { return {
writable: writablePermission, // whether the user can write to the API writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc oidc: context.config.oidc
? { ? {
issuer: context.config.oidc.issuer, issuer: context.config.oidc.issuer,
} }
: undefined, : undefined,
roles, roles,
magic, magic,
users, users,
}; };
} }
export const action = userAction; export const action = userAction;
export default function Page({ loaderData }: Route.ComponentProps) { export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.users); const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
// This useEffect is entirely for the purpose of updating the users when the // 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 // 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 // the idea is to treat data.users as the source of truth and update the
// local state when it changes. // local state when it changes.
useEffect(() => { useEffect(() => {
setUsers(loaderData.users); setUsers(loaderData.users);
}, [loaderData.users]); }, [loaderData.users]);
return ( return (
<> <>
<h1 className="text-2xl font-medium mb-1.5">Users</h1> <h1 className="mb-1.5 text-2xl font-medium">Users</h1>
<p className="mb-8 text-md"> <p className="text-md mb-8">Manage the users in your network and their permissions.</p>
Manage the users in your network and their permissions. <ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
</p> <div className="overflow-x-auto">
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} /> <table className="w-full min-w-[640px] table-auto rounded-lg">
<div className="overflow-x-auto"> <thead className="text-headplane-600 dark:text-headplane-300">
<table className="table-auto w-full rounded-lg min-w-[640px]"> <tr className="px-0.5 text-left">
<thead className="text-headplane-600 dark:text-headplane-300"> <th className="pb-2 text-xs font-bold uppercase">User</th>
<tr className="text-left px-0.5"> <th className="pb-2 text-xs font-bold uppercase">Role</th>
<th className="uppercase text-xs font-bold pb-2">User</th> <th className="pb-2 text-xs font-bold uppercase">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Role</th> <th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th> </tr>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th> </thead>
</tr> <tbody
</thead> className={cn(
<tbody "divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
className={cn( "border-t border-headplane-100 dark:border-headplane-800",
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top', )}
'border-t border-headplane-100 dark:border-headplane-800', >
)} {users
> .sort((a, b) => a.name.localeCompare(b.name))
{users .map((user) => (
.sort((a, b) => a.name.localeCompare(b.name)) <UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
.map((user) => ( ))}
<UserRow </tbody>
key={user.id} </table>
role={loaderData.roles[users.indexOf(user)]} </div>
user={user} </>
/> );
))}
</tbody>
</table>
</div>
</>
);
} }
+2 -1
View File
@@ -9,7 +9,8 @@ export interface Machine {
ipAddresses: string[]; ipAddresses: string[];
name: string; name: string;
user: User; // User can be null for tag-only nodes in Headscale 0.28+
user?: User;
lastSeen: string; lastSeen: string;
expiry: string | null; expiry: string | null;