feat: refactor several components and clean up ui

This commit is contained in:
Aarnav Tale
2026-03-09 22:09:58 -04:00
parent a53fd31d27
commit 6470f5a821
49 changed files with 1522 additions and 1355 deletions
+176 -207
View File
@@ -1,220 +1,189 @@
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
import Notice from "~/components/Notice";
import Tabs from "~/components/Tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import { Differ, Editor } from "./components/cm.client";
export const loader = aclLoader;
export const action = aclAction;
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
if (fetcher.data.success === true) {
toast("Updated policy");
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
onChange={setCodePolicy}
value={codePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control List policy. Please
contact your administrator to request access or to make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your Headscale
configuration. This means that the ACL file cannot be edited through the web interface. In
order to resolve this, you'll need to set <Code>policy.mode</Code> to{" "}
<Code>database</Code> in your Headscale configuration.
</Notice>
) : undefined}
<h1 className="mb-4 text-2xl font-medium">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network. You can find more
information about the ACL file in the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
and the{" "}
<Link external styled to="https://headscale.net/stable/ref/acls/">
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice title={fetcher.data.error.split(":")[0] ?? "Error"} variant="error">
{fetcher.data.error.split(":").slice(1).join(": ") ??
"An unknown error occurred while trying to update the ACL policy."}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="mt-4 w-1/2 text-center">
Previewing rules is not available yet. This feature is still in development and is
pretty complicated to implement. Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled || fetcher.state !== "idle" || codePolicy.length === 0 || codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append("policy", codePolicy);
fetcher.submit(formData, { method: "PATCH" });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={disabled || fetcher.state !== "idle" || codePolicy === policy}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes("reading policy from path") &&
error.data.rawData.includes("no such file or directory")
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does not exist on the
server. This usually indicates that Headscale is running in <Code>file</Code> mode for
ACLs, and the specified policy file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you can take:
</Card.Text>
<ul className="mt-2 ml-4 list-outside list-disc space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale configuration.
</li>
<li>
Alternatively, you can switch Headscale to use <Code>database</Code> mode for ACLs by
updating your Headscale configuration. This will allow Headplane to manage the ACL
policy directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
throw error;
}
+14 -12
View File
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
@@ -34,11 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
isExternal
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -54,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
case "INVALID_API_KEY": {
messages.push({
key: error,
node: (
@@ -65,8 +61,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT":
case "MISSING_AUTHORIZATION_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -77,8 +74,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT":
case "MISSING_TOKEN_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -89,8 +87,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT":
case "MISSING_USERINFO_ENDPOINT": {
messages.push({
key: error,
node: (
@@ -101,8 +100,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "MISSING_REQUIRED_CLAIMS":
case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
@@ -113,8 +113,9 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
case "UNKNOWN_ERROR":
case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
@@ -125,6 +126,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
),
});
break;
}
}
}
+5 -5
View File
@@ -27,7 +27,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
// For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
@@ -63,14 +63,14 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
// Been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
// Which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
@@ -103,8 +103,8 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
isExternal
name="Headplane Common Issues"
external
styled
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
+116 -140
View File
@@ -1,153 +1,129 @@
import { Info } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
import { Info } from "lucide-react";
import { Form, useSubmit } from "react-router";
import Button from "~/components/Button";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
import AddNS from "../dialogs/add-ns";
interface Props {
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
}
export default function ManageNS({
nameservers,
isDisabled,
overrideLocalDns,
}: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
>
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
/>
))}
export default function ManageNS({ nameservers, isDisabled, overrideLocalDns }: Props) {
return (
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS queries.{" "}
<Link external styled to="https://tailscale.com/kb/1054/dns">
Learn more
</Link>
</p>
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
isDisabled={isDisabled}
isGlobal={key === "global"}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
/>
))}
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
{isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div>
</div>
);
}
interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
}
function NameserverList({
isGlobal,
isDisabled,
nameservers,
overrideLocalDns,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
function NameserverList({ isGlobal, isDisabled, nameservers, overrideLocalDns, name }: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
if (list.length === 0) {
return null;
}
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
{isGlobal ? (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: 'override_dns',
override_dns: v ? 'true' : 'false',
},
{
method: 'POST',
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input
name="split_name"
type="hidden"
value={isGlobal ? 'global' : name}
/>
<Button
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
return (
<div className="mb-8">
<div className="mb-2 flex items-center justify-between">
{isGlobal ? (
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-md font-medium opacity-80">Global Nameservers</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve names outside the
tailnet. When disabled (default), devices will prefer their local DNS
configuration.
<Link external styled to="https://tailscale.com/kb/1054/dns#global-nameservers">
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: "override_dns",
override_dns: v ? "true" : "false",
},
{
method: "POST",
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
? list.map((ns) => (
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input name="split_name" type="hidden" value={isGlobal ? "global" : name} />
<Button
className={cn("px-2 py-1 rounded-md", "text-red-500 dark:text-red-400")}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
</Form>
</TableList.Item>
))
: undefined}
</TableList>
</div>
);
}
+1 -5
View File
@@ -20,11 +20,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now, only <Code>A</Code>{" "}
and <Code>AAAA</Code> records are supported.{" "}
<Link
isExternal
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
>
<Link external styled to="https://headscale.net/stable/ref/dns">
Learn More
</Link>
</p>
+153
View File
@@ -0,0 +1,153 @@
import { Check, Copy } from "lucide-react";
import { Form, redirect } from "react-router";
import androidSvg from "~/assets/android.svg";
import iosSvg from "~/assets/ios.svg";
import linuxSvg from "~/assets/linux.svg";
import macosSvg from "~/assets/macos.svg";
import windowsSvg from "~/assets/windows.svg";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/link";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import toast from "~/utils/toast";
import type { Route } from "./+types/home";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (context.auth.can(principal, Capabilities.ui_access)) {
return redirect("/machines");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined;
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
try {
const users = await api.getUsers();
const hsUser = users.find((u) => u.id === principal.user.headscaleUserId);
linkedUserName = hsUser?.name;
} catch {
// API unavailable, skip linked user resolution
}
}
return { linkedUserName };
}
const downloads = [
{
href: "https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe",
icon: windowsSvg,
name: "Windows",
note: "Windows 10+",
},
{
href: "https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg",
icon: macosSvg,
name: "macOS",
note: "macOS Big Sur+",
},
{
href: "https://apps.apple.com/us/app/tailscale/id1470499037",
icon: iosSvg,
name: "iOS",
note: "iOS 15+",
},
{
href: "https://play.google.com/store/apps/details?id=com.tailscale.ipn",
icon: androidSvg,
name: "Android",
note: "Android 8+",
},
];
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<div className="mx-auto mt-6 mb-24 flex max-w-2xl flex-col gap-4">
{loaderData.linkedUserName && (
<Card variant="raised" className="flex max-w-2xl items-center gap-4">
<Check className="inline-flex size-4" />
<Card.Text className="text-sm">
Your account is linked to Headscale user <strong>{loaderData.linkedUserName}</strong>.
</Card.Text>
</Card>
)}
<Card variant="flat" className="max-w-2xl">
<Card.Title>Access your network via Tailscale</Card.Title>
<Card.Text className="mt-1">
You've successfully authenticated but don't have access to the dashboard. You can still
connect to your Headscale network by installing Tailscale.
</Card.Text>
<div className="mt-4 rounded-lg border border-mist-200 p-3 dark:border-mist-700">
<div className="flex items-center gap-2">
<img alt="Linux" className="w-4" src={linuxSvg} />
<span className="text-sm font-medium">Linux</span>
</div>
<div className="mt-2 flex items-center gap-2">
<code
className={cn(
"flex-1 rounded-md px-3 py-2 text-xs h-8",
"bg-mist-100 dark:bg-mist-800",
)}
>
curl -fsSL https://tailscale.com/install.sh | sh
</code>
<Button
className="h-8 p-1 px-2"
variant="ghost"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast("Copied to clipboard");
}}
>
<Copy className="size-4" />
</Button>
</div>
<p className="mt-1 text-xs text-mist-500 dark:text-mist-400">
<Link
external
styled
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
{downloads.map((dl) => (
<a
key={dl.name}
className={cn(
"flex flex-col items-center gap-2 rounded-lg p-3",
"border border-mist-200 dark:border-mist-700",
"hover:bg-mist-100 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
"transition-colors",
)}
href={dl.href}
rel="noreferrer"
target="_blank"
>
<img alt={dl.name} className="h-6" src={dl.icon} />
<span className="text-sm font-medium">{dl.name}</span>
<span className="text-xs text-mist-500 dark:text-mist-400">{dl.note}</span>
</a>
))}
</div>
<Card.Text className="mt-8 text-center text-xs text-mist-600 dark:text-mist-300">
Need access to the dashboard? Contact your administrator to request access.
</Card.Text>
</Card>
</div>
);
}
+35 -30
View File
@@ -3,7 +3,7 @@ import { useMemo } from "react";
import Chip from "~/components/Chip";
import Link from "~/components/link";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import StatusCircle from "~/components/StatusCircle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import { PopulatedNode } from "~/utils/node-info";
import type { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
@@ -76,29 +76,28 @@ export default function MachineRow({
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<Menu>
<MenuTrigger className="rounded-full bg-transparent p-1 hover:bg-mist-100 dark:hover:bg-mist-800">
<ChevronDown className="h-4 w-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast("Copied IP address to clipboard");
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</MenuTrigger>
<MenuContent align="end">
{ipOptions.map((ip) => (
<MenuItem
key={ip}
onClick={async () => {
await navigator.clipboard.writeText(ip);
toast("Copied IP address to clipboard");
}}
>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</MenuItem>
))}
</MenuContent>
</Menu>
</div>
</td>
@@ -190,25 +189,31 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case "exit-approved":
case "exit-waiting":
case "exit-waiting": {
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
}
case "subnet-approved":
case "subnet-waiting":
case "subnet-waiting": {
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
}
case "expired":
case "no-expiry":
case "no-expiry": {
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
}
case "tailscale-ssh":
case "tailscale-ssh": {
return <TailscaleSSHTag key={tag} />;
}
case "headplane-agent":
case "headplane-agent": {
return <HeadplaneAgentTag key={tag} />;
}
default:
default: {
return null;
}
}
});
}
+32 -33
View File
@@ -2,7 +2,7 @@ import { Cog, Ellipsis, SquareTerminal } from "lucide-react";
import { useState } from "react";
import Button from "~/components/Button";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { User } from "~/types";
import cn from "~/utils/cn";
import { PopulatedNode } from "~/utils/node-info";
@@ -140,39 +140,38 @@ export default function MachineMenu({
</Button>
)
) : undefined}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
<p>Machine Settings</p>
</Menu.Button>
) : (
<Menu.IconButton
className="w-10 bg-transparent py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800"
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
disabledKeys={node.expired ? ["expire"] : []}
onAction={(key) => setModal(key as Modal)}
<Menu disabled={isDisabled}>
<MenuTrigger
className={
isFullButton
? "gap-x-2 rounded-md border border-mist-200 bg-white px-3.5 py-2 text-sm font-medium hover:bg-mist-50 dark:border-mist-700 dark:bg-mist-800/50 dark:hover:bg-mist-700/50"
: "w-10 rounded-full bg-transparent p-1 hover:bg-mist-100 dark:hover:bg-mist-800"
}
>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
</Menu.Section>
<Menu.Section>
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
{isFullButton ? (
<>
<Cog className="h-5" />
<p>Machine Settings</p>
</>
) : (
<Ellipsis className="h-5" />
)}
</MenuTrigger>
<MenuContent>
<MenuItem onClick={() => setModal("rename")}>Edit machine name</MenuItem>
<MenuItem onClick={() => setModal("routes")}>Edit route settings</MenuItem>
<MenuItem onClick={() => setModal("tags")}>Edit ACL tags</MenuItem>
{supportsNodeOwnerChange && (
<MenuItem onClick={() => setModal("move")}>Change owner</MenuItem>
)}
<MenuSeparator />
<MenuItem variant="danger" disabled={node.expired} onClick={() => setModal("expire")}>
Expire
</MenuItem>
<MenuItem variant="danger" onClick={() => setModal("remove")}>
Remove
</MenuItem>
</MenuContent>
</Menu>
</div>
);
+25 -30
View File
@@ -5,7 +5,7 @@ import { useNavigate } from "react-router";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu";
import Select from "~/components/Select";
import type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
@@ -51,35 +51,30 @@ export default function NewMachine(data: NewMachineProps) {
</Select>
</Dialog.Panel>
</Dialog>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === "register") {
setPushDialog(true);
return;
}
if (key === "pre-auth") {
navigate("/settings/auth-keys");
}
}}
>
<Menu.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
<Menu disabled={data.isDisabled}>
<MenuTrigger className="rounded-md bg-indigo-500 px-3.5 py-2 text-sm font-semibold text-white hover:bg-indigo-500/90 dark:bg-indigo-500/90 dark:hover:bg-indigo-500/80">
Add Device
</MenuTrigger>
<MenuContent>
<MenuItem
disabled={data.disabledKeys?.includes("register")}
onClick={() => setPushDialog(true)}
>
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</MenuItem>
<MenuItem
disabled={data.disabledKeys?.includes("pre-auth")}
onClick={() => navigate("/settings/auth-keys")}
>
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</MenuItem>
</MenuContent>
</Menu>
</>
);
+91 -103
View File
@@ -1,114 +1,102 @@
import { GlobeLock, RouteOff } from 'lucide-react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { PopulatedNode } from '~/utils/node-info';
import { GlobeLock, RouteOff } from "lucide-react";
import { useFetcher } from "react-router";
import Dialog from "~/components/Dialog";
import Link from "~/components/link";
import Switch from "~/components/Switch";
import TableList from "~/components/TableList";
import { PopulatedNode } from "~/utils/node-info";
interface RoutesProps {
node: PopulatedNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
node: PopulatedNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
const fetcher = useFetcher();
const fetcher = useFetcher();
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">
No routes are advertised by this machine
</p>
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set('routes', [route].join(','));
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising IP ranges as subnet
routes.{" "}
<Link external styled to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">No routes are advertised by this machine</p>
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
form.set("routes", [route].join(","));
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</TableList.Item>
))}
</TableList>
<Dialog.Text className="font-bold mt-8">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
</TableList.Item>
) : (
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={node.customRouting.exitApproved}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set(
'routes',
node.customRouting.exitRoutes
.map((route) => route)
.join(','),
);
form.set("enabled", String(checked));
fetcher.submit(form, {
method: "POST",
});
}}
/>
</TableList.Item>
))}
</TableList>
<Dialog.Text className="mt-8 font-bold">Exit nodes</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.{" "}
<Link external styled to="https://tailscale.com/kb/1103/exit-nodes">
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
</TableList.Item>
) : (
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={node.customRouting.exitApproved}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set("action_id", "update_routes");
form.set("node_id", node.id);
form.set("routes", node.customRouting.exitRoutes.map((route) => route).join(","));
form.set('enabled', String(checked));
fetcher.submit(form, {
method: 'POST',
});
}}
/>
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</Dialog>
);
form.set("enabled", String(checked));
fetcher.submit(form, {
method: "POST",
});
}}
/>
</TableList.Item>
)}
</TableList>
</Dialog.Panel>
</Dialog>
);
}
+15 -15
View File
@@ -22,13 +22,15 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}, [tag, tags]);
const tagIsInvalid = useMemo(
() => tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag),
[tag, tags],
);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
const validNodeTags = useMemo(
() => existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [],
[tags],
);
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
@@ -53,7 +55,9 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
@@ -72,11 +76,7 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See the{" "}
<Link
isExternal
name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
>
<Link external styled to="https://tailscale.com/kb/1068/acl-tags">
Tailscale documentation
</Link>{" "}
for more information.
@@ -119,9 +119,9 @@ export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsP
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
{validNodeTags.map((nodeTag) => (
<Select.Item key={nodeTag}>{nodeTag}</Select.Item>
))}
</Select>
<Button
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
+7 -11
View File
@@ -45,18 +45,18 @@ 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 tags = [...node.tags].toSorted();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
agent: context.agents?.agentID(),
existingTags: sortNodeTags(nodes),
magic,
node: enhancedNode,
stats: lookup?.[enhancedNode.nodeKey],
supportsNodeOwnerChange: supportsNodeOwnerChange,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
@@ -129,11 +129,7 @@ export default function Page({
<div className="mb-4 flex items-center justify-between">
<p>
Subnets let you expose physical network routes onto Tailscale.{" "}
<Link
isExternal
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
<Link external styled to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</p>
+23 -19
View File
@@ -42,17 +42,17 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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: context.auth.can(principal, Capabilities.generate_authkeys),
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
magic,
nodes,
populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url,
server: context.config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange,
users,
writable: writablePermission,
};
}
@@ -69,19 +69,26 @@ export default function Page({ loaderData }: Route.ComponentProps) {
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;
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) => {
nodes = [...nodes].toSorted((a, b) => {
let comparison = 0;
switch (sortField) {
case "name":
case "name": {
comparison = a.givenName.localeCompare(b.givenName);
break;
}
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
@@ -119,13 +126,14 @@ export default function Page({ loaderData }: Route.ComponentProps) {
}
break;
}
case "lastSeen":
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;
@@ -150,11 +158,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link
isExternal
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
<Link external styled to="https://tailscale.com/kb/1372/manage-devices">
Learn more
</Link>
</p>
@@ -1,4 +1,5 @@
import { Key, useEffect, useRef, useState } from "react";
import type { Key } from "react";
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Button from "~/components/Button";
@@ -21,9 +22,13 @@ interface AddAuthKeyProps {
}
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
if (!subject) return undefined;
if (!subject) {
return undefined;
}
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) return false;
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
});
}
@@ -76,7 +81,9 @@ export default function AddAuthKey({
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
@@ -203,11 +210,7 @@ export default function AddAuthKey({
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link
isExternal
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
<Link external styled to="https://tailscale.com/kb/1111/ephemeral-nodes">
Learn more
</Link>
</Dialog.Text>
+11 -15
View File
@@ -32,7 +32,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// older versions don't support this endpoint
// Older versions don't support this endpoint
}
if (allKeys !== null) {
@@ -47,12 +47,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
keys = [];
const tagOnly = keysByUser.get(null);
if (tagOnly?.length) {
keys.push({ user: null, preAuthKeys: tagOnly });
keys.push({ preAuthKeys: tagOnly, user: null });
}
for (const user of users) {
const userKeys = keysByUser.get(user.id);
if (userKeys?.length) {
keys.push({ user, preAuthKeys: userKeys });
keys.push({ preAuthKeys: userKeys, user });
}
}
} else {
@@ -66,34 +66,34 @@ export async function loader({ request, context }: Route.LoaderArgs) {
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return { success: true as const, user, preAuthKeys };
return { preAuthKeys, success: true as const, user };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return { success: false as const, user, error, preAuthKeys: [] as const };
return { error, preAuthKeys: [] as const, success: false as const, user };
}
}),
);
keys = results
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
.map(({ user, preAuthKeys }) => ({ preAuthKeys, user }));
missing = results
.filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
.map(({ user, error }) => ({ error, user }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
return {
access: canGenerateAny || canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
keys,
missing,
users,
access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
url: context.config.headscale.public_url ?? context.config.headscale.url,
users,
};
}
@@ -192,11 +192,7 @@ export default function Page({
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
isExternal
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
<Link external styled to="https://tailscale.com/kb/1085/auth-keys/">
Tailscale documentation
</Link>
</p>
+2 -10
View File
@@ -28,11 +28,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
<p>
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
isExternal
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
<Link external styled to="https://tailscale.com/kb/1085/auth-keys/">
Tailscale documentation
</Link>
</p>
@@ -52,11 +48,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
domains, groups, or users to authenticate. This can be used to limit access to your
Tailnet to only certain users or groups and Headplane will also respect these settings
when authenticating.{" "}
<Link
isExternal
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
<Link external styled to="https://headscale.net/stable/ref/oidc/#basic-configuration">
Learn More
</Link>
</p>
@@ -28,12 +28,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
access: context.auth.can(principal, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
writable: context.hs.writable(),
};
}
@@ -69,11 +69,7 @@ export default function Page({ loaderData: { access, writable, settings } }: Rou
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
certain users or groups and Headplane will also respect these settings when
authenticating.{" "}
<Link
isExternal
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
<Link external styled to="https://headscale.net/stable/ref/oidc/#basic-configuration">
Learn More
</Link>
</p>
+3 -13
View File
@@ -21,28 +21,18 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{" "}
<Link isExternal name="OIDC Provider" to={oidc.issuer}>
<Link external styled to={oidc.issuer}>
OpenID Connect provider
</Link>
{". "}
Groups and user information do not automatically sync.{" "}
<Link
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
) : (
<>
Users are not managed externally. Using OpenID Connect can create a better
experience when using Headscale.{" "}
<Link
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
)}
</p>
+22 -17
View File
@@ -1,7 +1,7 @@
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import Menu from "~/components/Menu";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { Machine, User } from "~/types";
import Delete from "../dialogs/delete-user";
@@ -74,23 +74,28 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp
/>
)}
<Menu disabledKeys={disabledKeys}>
<Menu.IconButton
className="w-10 bg-transparent py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800"
label="User Options"
>
<Menu>
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="link">Link Headscale user</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</MenuTrigger>
<MenuContent>
<MenuItem disabled={disabledKeys.includes("rename")} onClick={() => setModal("rename")}>
Rename user
</MenuItem>
<MenuItem
disabled={disabledKeys.includes("reassign")}
onClick={() => setModal("reassign")}
>
Change role
</MenuItem>
<MenuItem disabled={disabledKeys.includes("link")} onClick={() => setModal("link")}>
Link Headscale user
</MenuItem>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</MenuContent>
</Menu>
</>
);
+86 -97
View File
@@ -1,105 +1,94 @@
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import RadioGroup from '~/components/RadioGroup';
import { Roles } from '~/server/web/roles';
import { User } from '~/types';
import Dialog from "~/components/Dialog";
import Link from "~/components/link";
import Notice from "~/components/Notice";
import RadioGroup from "~/components/RadioGroup";
import { Roles } from "~/server/web/roles";
import { User } from "~/types";
interface ReassignProps {
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({
user,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
>
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === 'owner' ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.headplaneRole === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind that I have not
fully implemented permissions yet and some things may be accessible to everyone. The only
fully completed role is Member.{" "}
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === "owner" ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== "owner")
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'admin':
return {
name: 'Admin',
desc: 'Can view the admin console, manage network, machine, and user settings.',
};
case 'network_admin':
return {
name: 'Network Admin',
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
};
case 'it_admin':
return {
name: 'IT Admin',
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
};
case 'auditor':
return {
name: 'Auditor',
desc: 'Can view the admin console.',
};
case 'member':
return {
name: 'Member',
desc: 'Cannot view the admin console.',
};
default:
return {
name: 'Unknown',
desc: 'Unknown',
};
}
switch (role) {
case "admin":
return {
name: "Admin",
desc: "Can view the admin console, manage network, machine, and user settings.",
};
case "network_admin":
return {
name: "Network Admin",
desc: "Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.",
};
case "it_admin":
return {
name: "IT Admin",
desc: "Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.",
};
case "auditor":
return {
name: "Auditor",
desc: "Can view the admin console.",
};
case "member":
return {
name: "Member",
desc: "Cannot view the admin console.",
};
default:
return {
name: "Unknown",
desc: "Unknown",
};
}
}
+22 -21
View File
@@ -9,7 +9,7 @@ import Link from "~/components/link";
import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { Machine } from "~/types";
import type { Machine } from "~/types";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
@@ -28,25 +28,30 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = "linux";
switch (os?.[0]) {
case "Windows":
case "Windows": {
osValue = "windows";
break;
case "Mac OS X":
}
case "Mac OS X": {
osValue = "macos";
break;
}
case "iPhone":
case "iPad":
case "iPad": {
osValue = "ios";
break;
}
case "Android":
case "Android": {
osValue = "android";
break;
}
default:
default: {
osValue = "linux";
break;
}
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
@@ -87,11 +92,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}));
}
}
} catch (e) {
log.debug("api", "Failed to lookup nodes %o", e);
} catch (error) {
log.debug("api", "Failed to lookup nodes %o", error);
}
return {
firstMachine,
headscaleUsers,
linkedUserName,
needsUserLink,
osValue,
user: {
subject: principal.user.subject,
name: principal.profile.name,
@@ -99,11 +109,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
username: principal.profile.username,
picture: principal.profile.picture,
},
osValue,
firstMachine,
needsUserLink,
linkedUserName,
headscaleUsers,
};
}
@@ -216,8 +221,8 @@ export default function Page({
<p className="mt-1 text-center text-xs text-mist-600 dark:text-mist-300">
Click this button to copy the command.{" "}
<Link
isExternal
name="Linux installation script"
external
styled
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
@@ -270,14 +275,10 @@ export default function Page({
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
isExternal
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
<Link external styled to="https://apps.apple.com/ca/app/tailscale/id1475387142">
macOS App Store
</Link>
{"."}
.
</p>
</Options.Item>
<Options.Item