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)}
+10 -10
View File
@@ -1,7 +1,9 @@
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;
@@ -11,20 +13,18 @@ interface MoveProps {
} }
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.
</Dialog.Text>
<input name="action_id" type="hidden" value="reassign" /> <input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} /> <input name="user_id" type="hidden" value={userId?.toString()} />
<Select <Select
defaultSelectedKey={machine.user.id} defaultSelectedKey={machine.user?.id}
isRequired isRequired
label="Owner" label="Owner"
name="user" name="user"
+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,
}); });
+8 -2
View File
@@ -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
+116 -129
View File
@@ -1,40 +1,37 @@
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()]);
@@ -59,10 +56,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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,
Capabilities.generate_authkeys,
),
subject: user.subject, subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange, supportsNodeOwnerChange: supportsNodeOwnerChange,
}; };
@@ -70,12 +64,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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();
@@ -83,8 +77,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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;
}); });
@@ -92,18 +85,18 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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];
@@ -115,11 +108,11 @@ export default function Page({ loaderData }: Route.ComponentProps) {
} }
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++) {
@@ -132,17 +125,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
} }
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;
@@ -150,20 +142,20 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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"
@@ -173,7 +165,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</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}
@@ -193,91 +185,91 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<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}
@@ -291,58 +283,58 @@ export default function Page({ loaderData }: Route.ComponentProps) {
{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>
@@ -350,14 +342,14 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</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}"
@@ -367,16 +359,11 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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
? loaderData.agent === node.nodeKey
: undefined
}
isDisabled={ isDisabled={
loaderData.writable loaderData.writable
? false // If the user has write permissions, they can edit all machines ? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !== : node.user?.providerId?.split("/").pop() !== loaderData.subject
loaderData.subject
} }
key={node.id} key={node.id}
magic={loaderData.magic} magic={loaderData.magic}
+63 -77
View File
@@ -1,18 +1,20 @@
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);
@@ -20,28 +22,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
// 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;
} }
@@ -50,13 +52,14 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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
if (!n.user || n.user.provider !== "oidc") {
return false; 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;
} }
@@ -71,7 +74,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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 {
@@ -98,12 +101,12 @@ export default function Page({
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!
@@ -111,15 +114,11 @@ export default function Page({
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"
defaultSelectedKey={osValue}
label="Download Selector"
>
<Options.Item <Options.Item
key="linux" key="linux"
title={ title={
@@ -130,19 +129,19 @@ export default function Page({
} }
> >
<Button <Button
className="flex text-md font-mono" className="text-md flex font-mono"
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
'curl -fsSL https://tailscale.com/install.sh | sh', "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"
@@ -170,7 +169,7 @@ export default function Page({
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>
@@ -178,10 +177,7 @@ export default function Page({
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"
icon="streamline-logos:mac-finder-logo-solid"
/>
<span>macOS</span> <span>macOS</span>
</div> </div>
} }
@@ -196,17 +192,17 @@ export default function Page({
Download for macOS Download for macOS
</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 macOS Big Sur 11.0 or later. Requires macOS Big Sur 11.0 or later.
<br /> <br />
You can also download Tailscale on the{' '} You can also download Tailscale on the{" "}
<Link <Link
name="macOS App Store" name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142" to="https://apps.apple.com/ca/app/tailscale/id1475387142"
> >
macOS App Store macOS App Store
</Link> </Link>
{'.'} {"."}
</p> </p>
</Options.Item> </Options.Item>
<Options.Item <Options.Item
@@ -228,7 +224,7 @@ export default function Page({
Download for iOS Download for iOS
</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 iOS 15 or later. Requires iOS 15 or later.
</p> </p>
</Options.Item> </Options.Item>
@@ -251,7 +247,7 @@ export default function Page({
Download for Android Download for Android
</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 Android 8 or later. Requires Android 8 or later.
</p> </p>
</Options.Item> </Options.Item>
@@ -259,29 +255,22 @@ export default function Page({
</Card> </Card>
<Card variant="flat"> <Card variant="flat">
{firstMachine ? ( {firstMachine ? (
<div className="flex flex-col justify-between h-full"> <div className="flex h-full flex-col justify-between">
<Card.Title className="mb-8"> <Card.Title className="mb-8">
Success! Success!
<br /> <br />
We found your first device We found your first device
</Card.Title> </Card.Title>
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4"> <div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<StatusCircle <StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
className="size-6 mt-3"
isOnline={firstMachine.online}
/>
<div> <div>
<p className="font-semibold leading-snug"> <p className="leading-snug font-semibold">{firstMachine.givenName}</p>
{firstMachine.givenName} <p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
</p>
<p className="text-sm font-mono opacity-50">
{firstMachine.name}
</p>
<div className="mt-6"> <div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p> <p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => ( {firstMachine.ipAddresses.map((ip) => (
<p className="text-xs font-mono opacity-50" key={ip}> <p className="font-mono text-xs opacity-50" key={ip}>
{ip} {ip}
</p> </p>
))} ))}
@@ -296,27 +285,24 @@ export default function Page({
</NavLink> </NavLink>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center gap-4 h-full"> <div className="flex h-full flex-col items-center justify-center gap-4">
<span className="relative flex size-4"> <span className="relative flex size-4">
<span <span
className={cn( className={cn(
'absolute inline-flex h-full w-full', "absolute inline-flex h-full w-full",
'rounded-full opacity-75 animate-ping', "rounded-full opacity-75 animate-ping",
'bg-headplane-500', "bg-headplane-500",
)} )}
/> />
<span <span
className={cn( className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
'relative inline-flex size-4 rounded-full',
'bg-headplane-400',
)}
/> />
</span> </span>
<p className="font-lg">Waiting for your first device...</p> <p className="font-lg">Waiting for your first device...</p>
</div> </div>
)} )}
</Card> </Card>
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip"> <NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
<Button className="flex items-center gap-1"> <Button className="flex items-center gap-1">
I already know what I'm doing I already know what I'm doing
<ArrowRight className="p-1" /> <ArrowRight className="p-1" />
+36 -41
View File
@@ -1,12 +1,16 @@
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[];
@@ -18,30 +22,27 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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,
@@ -51,26 +52,26 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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";
}), }),
); );
@@ -109,35 +110,29 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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.
</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} /> <ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<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 className="uppercase text-xs font-bold pb-2">User</th> <th className="pb-2 text-xs font-bold uppercase">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th> <th className="pb-2 text-xs font-bold uppercase">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th> <th className="pb-2 text-xs font-bold uppercase">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th> <th className="pb-2 text-xs font-bold uppercase">Last Seen</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",
)} )}
> >
{users {users
.sort((a, b) => a.name.localeCompare(b.name)) .sort((a, b) => a.name.localeCompare(b.name))
.map((user) => ( .map((user) => (
<UserRow <UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))} ))}
</tbody> </tbody>
</table> </table>
+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;