mirror of
https://github.com/tale/headplane.git
synced 2026-08-31 17:28:14 +00:00
feat: refactor several components and clean up ui
This commit is contained in:
+176
-207
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user