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