mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
fix: handle null user for tag-only nodes in Headscale 0.28+
In Headscale 0.28+, nodes can be registered with tag-only preauth keys which have no associated user. This change updates the Machine type to make user optional and adds null checks throughout the codebase where node.user is accessed. Fixes tag node operations (rename, add tags) that previously failed with 'Unexpected Server Error' when accessing node.user.providerId on nodes without a user association. Refs: #432
This commit is contained in:
@@ -63,7 +63,9 @@ export default function MachineRow({
|
||||
{node.givenName}
|
||||
</p>
|
||||
<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>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
|
||||
@@ -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<Key | null>(machine.user.id);
|
||||
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel isDisabled={userId === machine.user.id}>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<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="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel isDisabled={userId === machine.user?.id}>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<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="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user?.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-x-2.5">
|
||||
<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 className="p-2 pl-4">
|
||||
@@ -241,7 +243,11 @@ export default function Page({
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute
|
||||
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
|
||||
|
||||
+355
-368
@@ -1,394 +1,381 @@
|
||||
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
||||
import type { Route } from './+types/overview';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Input from "~/components/Input";
|
||||
import Link from "~/components/Link";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
import MachineRow from "./components/machine-row";
|
||||
import NewMachine from "./dialogs/new";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
throw new Error('Missing user session. Please log in again.');
|
||||
}
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
throw new Error("Missing user session. Please log in again.");
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
);
|
||||
const check = await context.sessions.check(request, Capabilities.read_machines);
|
||||
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
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_machines,
|
||||
);
|
||||
const writablePermission = await context.sessions.check(request, Capabilities.write_machines);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
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<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("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 (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{' '}
|
||||
<Link
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!loaderData.writable}
|
||||
server={loaderData.publicServer ?? loaderData.server}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Input
|
||||
label="Search machines"
|
||||
labelHidden
|
||||
maxLength={100}
|
||||
onChange={(value) => setSearchQuery(value.slice(0, 100))}
|
||||
placeholder="Search by name or IP address..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className={cn(
|
||||
'absolute right-2 top-1/2 -translate-y-1/2',
|
||||
'p-1 rounded-full',
|
||||
'text-headplane-400 hover:text-headplane-600',
|
||||
'dark:text-headplane-500 dark:hover:text-headplane-300',
|
||||
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
|
||||
)}
|
||||
onClick={() => setSearchQuery('')}
|
||||
type="button"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-headplane-500 whitespace-nowrap">
|
||||
{searchQuery
|
||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||
: `${loaderData.populatedNodes.length} machines`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto w-full rounded-lg min-w-[640px]">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'name'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by name"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('name')}
|
||||
type="button"
|
||||
>
|
||||
Name
|
||||
{sortField === 'name' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'ip'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="pb-2 w-1/4"
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
aria-label="Sort by IP address"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('ip')}
|
||||
type="button"
|
||||
>
|
||||
Addresses
|
||||
{sortField === 'ip' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based
|
||||
on their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'version'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by version"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('version')}
|
||||
type="button"
|
||||
>
|
||||
Version
|
||||
{sortField === 'version' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
) : undefined}
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === 'lastSeen'
|
||||
? sortDirection === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
className="uppercase text-xs font-bold pb-2"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by last seen"
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 cursor-pointer',
|
||||
'hover:text-headplane-900 dark:hover:text-headplane-100',
|
||||
)}
|
||||
onClick={() => handleSort('lastSeen')}
|
||||
type="button"
|
||||
>
|
||||
Last Seen
|
||||
{sortField === 'lastSeen' &&
|
||||
(sortDirection === 'asc' ? (
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{filteredAndSortedNodes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="py-8 text-center text-headplane-500"
|
||||
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
||||
>
|
||||
No machines found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent
|
||||
? loaderData.agent === node.nodeKey
|
||||
: undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col">
|
||||
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{" "}
|
||||
<Link
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
|
||||
isDisabled={!loaderData.writable}
|
||||
server={loaderData.publicServer ?? loaderData.server}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<div className="relative w-64">
|
||||
<Input
|
||||
label="Search machines"
|
||||
labelHidden
|
||||
maxLength={100}
|
||||
onChange={(value) => setSearchQuery(value.slice(0, 100))}
|
||||
placeholder="Search by name or IP address..."
|
||||
value={searchQuery}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
aria-label="Clear search"
|
||||
className={cn(
|
||||
"absolute right-2 top-1/2 -translate-y-1/2",
|
||||
"p-1 rounded-full",
|
||||
"text-headplane-400 hover:text-headplane-600",
|
||||
"dark:text-headplane-500 dark:hover:text-headplane-300",
|
||||
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
|
||||
)}
|
||||
onClick={() => setSearchQuery("")}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-headplane-500 text-sm whitespace-nowrap">
|
||||
{searchQuery
|
||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||
: `${loaderData.populatedNodes.length} machines`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === "name"
|
||||
? sortDirection === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
className="pb-2 text-xs font-bold uppercase"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by name"
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 cursor-pointer",
|
||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
||||
)}
|
||||
onClick={() => handleSort("name")}
|
||||
type="button"
|
||||
>
|
||||
Name
|
||||
{sortField === "name" &&
|
||||
(sortDirection === "asc" ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === "ip"
|
||||
? sortDirection === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
className="w-1/4 pb-2"
|
||||
>
|
||||
<div className="flex items-center gap-x-1">
|
||||
<button
|
||||
aria-label="Sort by IP address"
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
|
||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
||||
)}
|
||||
onClick={() => handleSort("ip")}
|
||||
type="button"
|
||||
>
|
||||
Addresses
|
||||
{sortField === "ip" &&
|
||||
(sortDirection === "asc" ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
))}
|
||||
</button>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="h-4 w-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based on their name and
|
||||
also at{" "}
|
||||
<Code>
|
||||
[name].
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === "version"
|
||||
? sortDirection === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
className="pb-2 text-xs font-bold uppercase"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by version"
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 cursor-pointer",
|
||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
||||
)}
|
||||
onClick={() => handleSort("version")}
|
||||
type="button"
|
||||
>
|
||||
Version
|
||||
{sortField === "version" &&
|
||||
(sortDirection === "asc" ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
) : undefined}
|
||||
<th
|
||||
aria-sort={
|
||||
sortField === "lastSeen"
|
||||
? sortDirection === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
className="pb-2 text-xs font-bold uppercase"
|
||||
>
|
||||
<button
|
||||
aria-label="Sort by last seen"
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 cursor-pointer",
|
||||
"hover:text-headplane-900 dark:hover:text-headplane-100",
|
||||
)}
|
||||
onClick={() => handleSort("lastSeen")}
|
||||
type="button"
|
||||
>
|
||||
Last Seen
|
||||
{sortField === "lastSeen" &&
|
||||
(sortDirection === "asc" ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
))}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
|
||||
"border-t border-headplane-100 dark:border-headplane-800",
|
||||
)}
|
||||
>
|
||||
{filteredAndSortedNodes.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="text-headplane-500 py-8 text-center"
|
||||
colSpan={loaderData.agent !== undefined ? 5 : 4}
|
||||
>
|
||||
No machines found matching "{searchQuery}"
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredAndSortedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user?.providerId?.split("/").pop() !== loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+293
-307
@@ -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 <strong>{user.email}</strong>
|
||||
</>
|
||||
) : (
|
||||
'with your OIDC provider'
|
||||
);
|
||||
const subject = user.email ? (
|
||||
<>
|
||||
as <strong>{user.email}</strong>
|
||||
</>
|
||||
) : (
|
||||
"with your OIDC provider"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed w-full h-screen flex items-center px-4">
|
||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
Let's get set up
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
Install Tailscale and sign in {subject}. Once you sign in on a
|
||||
device, it will be automatically added to your Headscale network.
|
||||
</Card.Text>
|
||||
return (
|
||||
<div className="fixed flex h-screen w-full items-center px-4">
|
||||
<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.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
Let's get set up
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
|
||||
automatically added to your Headscale network.
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={osValue}
|
||||
label="Download Selector"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
'curl -fsSL https://tailscale.com/install.sh | sh',
|
||||
);
|
||||
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="text-md flex font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Click this button to copy the command.{' '}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon
|
||||
className="ml-1 w-4"
|
||||
icon="streamline-logos:mac-finder-logo-solid"
|
||||
/>
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{' '}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{'.'}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card variant="flat">
|
||||
{firstMachine ? (
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<Card.Title className="mb-8">
|
||||
Success!
|
||||
<br />
|
||||
We found your first device
|
||||
</Card.Title>
|
||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle
|
||||
className="size-6 mt-3"
|
||||
isOnline={firstMachine.online}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
{firstMachine.givenName}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">
|
||||
{firstMachine.name}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p className="text-xs font-mono opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 h-full">
|
||||
<span className="relative flex size-4">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full',
|
||||
'rounded-full opacity-75 animate-ping',
|
||||
'bg-headplane-500',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-4 rounded-full',
|
||||
'bg-headplane-400',
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
||||
Click this button to copy the command.{" "}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{" "}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{"."}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<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"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card variant="flat">
|
||||
{firstMachine ? (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<Card.Title className="mb-8">
|
||||
Success!
|
||||
<br />
|
||||
We found your first device
|
||||
</Card.Title>
|
||||
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
|
||||
<div>
|
||||
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
|
||||
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p className="font-mono text-xs opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<span className="relative flex size-4">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full",
|
||||
"rounded-full opacity-75 animate-ping",
|
||||
"bg-headplane-500",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
|
||||
/>
|
||||
</span>
|
||||
<p className="font-lg">Waiting for your first device...</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink className="col-span-2 mx-auto w-max" 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
@@ -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<UserMachine[]>(loaderData.users);
|
||||
const [users, setUsers] = useState<UserMachine[]>(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 (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table-auto w-full rounded-lg min-w-[640px]">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'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))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
|
||||
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"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))
|
||||
.map((user) => (
|
||||
<UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user