+
+ );
+}
diff --git a/app/layouts/no-access.tsx b/app/layouts/no-access.tsx
deleted file mode 100644
index 4bee4d0..0000000
--- a/app/layouts/no-access.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-import { Icon } from "@iconify/react";
-import { Form } from "react-router";
-
-import Button from "~/components/Button";
-import Card from "~/components/Card";
-import Link from "~/components/link";
-import Options from "~/components/Options";
-import toast from "~/utils/toast";
-
-interface NoAccessProps {
- linkedUserName?: string;
- osValue?: string;
-}
-
-export default function NoAccess({ linkedUserName, osValue }: NoAccessProps) {
- return (
-
-
- {linkedUserName ? (
-
-
- ✓ Your account is linked to Headscale user {linkedUserName}.
-
-
- ) : undefined}
-
- Access your network via Tailscale
-
- You don't have dashboard access, but you can still connect to your Headscale network.
- Install Tailscale on your device to get started.
-
-
-
-
-
- Linux
-
- }
- >
-
-
- Click this button to copy the command.{" "}
-
- View script source
-
-
-
-
-
-
- Need dashboard access?
-
- Your account is signed in but doesn't have permission to manage the dashboard. Contact
- an administrator to request access.
-
-
-
-
-
- );
-}
diff --git a/app/layouts/shell.tsx b/app/layouts/shell.tsx
index 652d630..79a77b5 100644
--- a/app/layouts/shell.tsx
+++ b/app/layouts/shell.tsx
@@ -1,11 +1,11 @@
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
-import Header from "~/components/Header";
+import Header from "~/layout/header";
import { Capabilities } from "~/server/web/roles";
import { getUserDisplayName } from "~/utils/user";
-import { Route } from "./+types/shell";
+import type { Route } from "./+types/shell";
import NoAccess from "./no-access";
// This loads the bare minimum for the application to function
@@ -31,13 +31,13 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const user =
principal.kind === "oidc"
? {
- subject: principal.user.subject,
- name: principal.profile.name,
email: principal.profile.email,
- username: principal.profile.username,
+ name: principal.profile.name,
picture: principal.profile.picture,
+ subject: principal.user.subject,
+ username: principal.profile.username,
}
- : { subject: "api_key", name: principal.displayName };
+ : { name: principal.displayName, subject: "api_key" };
let linkedUserName: string | undefined;
let osValue: string | undefined;
@@ -57,31 +57,31 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
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;
+ }
}
}
return {
- config: context.hs.c,
- url: context.config.headscale.public_url ?? context.config.headscale.url,
- configAvailable: context.hs.readable(),
- debug: context.config.debug,
- user,
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
@@ -90,11 +90,16 @@ export async function loader({ request, context }: Route.LoaderArgs) {
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
- onboarding: request.url.endsWith("/onboarding"),
- noAccess,
- linkedUserName,
- osValue,
+ config: context.hs.c,
+ configAvailable: context.hs.readable(),
+ debug: context.config.debug,
healthy: await api.isHealthy(),
+ linkedUserName,
+ noAccess,
+ onboarding: request.url.endsWith("/onboarding"),
+ osValue,
+ url: context.config.headscale.public_url ?? context.config.headscale.url,
+ user,
};
} catch {
return redirect("/login", {
@@ -109,7 +114,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
-
+
>
@@ -118,7 +123,7 @@ export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
-
+
>
diff --git a/public/logo-dark-bg.svg b/app/logo/dark-bg.svg
similarity index 100%
rename from public/logo-dark-bg.svg
rename to app/logo/dark-bg.svg
diff --git a/public/logo-dark.svg b/app/logo/dark.svg
similarity index 100%
rename from public/logo-dark.svg
rename to app/logo/dark.svg
diff --git a/public/logo-light-bg.svg b/app/logo/light-bg.svg
similarity index 100%
rename from public/logo-light-bg.svg
rename to app/logo/light-bg.svg
diff --git a/public/logo-light.svg b/app/logo/light.svg
similarity index 100%
rename from public/logo-light.svg
rename to app/logo/light.svg
diff --git a/app/routes.ts b/app/routes.ts
index 8058b7c..0a9c1ce 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -2,7 +2,6 @@ import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
- index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
@@ -15,27 +14,26 @@ export default [
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
- // All the main logged-in dashboard routes
- // Double nested to separate error propagations
- layout("layouts/shell.tsx", [
+ // All the main logged-in routes
+ layout("layout/app.tsx", [
+ index("routes/home.tsx"),
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
- layout("layouts/dashboard.tsx", [
- ...prefix("/machines", [
- index("routes/machines/overview.tsx"),
- route("/:id", "routes/machines/machine.tsx"),
- ]),
- route("/users", "routes/users/overview.tsx"),
- route("/acls", "routes/acls/overview.tsx"),
- route("/dns", "routes/dns/overview.tsx"),
+ ...prefix("/machines", [
+ index("routes/machines/overview.tsx"),
+ route("/:id", "routes/machines/machine.tsx"),
+ ]),
- ...prefix("/settings", [
- index("routes/settings/overview.tsx"),
- route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
- route("/restrictions", "routes/settings/restrictions/overview.tsx"),
- // route('/local-agent', 'routes/settings/local-agent.tsx'),
- ]),
+ route("/users", "routes/users/overview.tsx"),
+ route("/acls", "routes/acls/overview.tsx"),
+ route("/dns", "routes/dns/overview.tsx"),
+
+ ...prefix("/settings", [
+ index("routes/settings/overview.tsx"),
+ route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
+ route("/restrictions", "routes/settings/restrictions/overview.tsx"),
+ // route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
];
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index d3c96db..0579dd5 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -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();
- 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();
+ 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 (
-
- {!access ? (
-
- 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.
-
- ) : !writable ? (
-
- The ACL policy mode is most likely set to file 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 policy.mode to database in your
- Headscale configuration.
-
- ) : undefined}
-
Access Control List (ACL)
-
- 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{' '}
-
- Tailscale ACL guide
- {' '}
- and the{' '}
-
- Headscale docs
-
- .
-
- 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.
-
-
-
-
-
-
-
- );
+ return (
+
+ {!access ? (
+
+ 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.
+
+ ) : !writable ? (
+
+ The ACL policy mode is most likely set to file 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 policy.mode to{" "}
+ database in your Headscale configuration.
+
+ ) : undefined}
+
Access Control List (ACL)
+
+ 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{" "}
+
+ Tailscale ACL guide
+ {" "}
+ and the{" "}
+
+ Headscale docs
+
+ .
+
+ 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.
+
+
+
+
+
+
+
+ );
}
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 (
-
-
-
- ACL Policy Unavailable
-
-
-
- The ACL policy is currently unavailable because the policy file does
- not exist on the server. This usually indicates that Headscale is
- running in file mode for ACLs, and the specified policy
- file is missing.
-
-
-
-
- In order to resolve this issue, there are two possible actions you
- can take:
-
-
-
- Create the ACL policy file at the specified path in your Headscale
- configuration.
-
-
- Alternatively, you can switch Headscale to use{' '}
- database mode for ACLs by updating your Headscale
- configuration. This will allow Headplane to manage the ACL policy
- directly through the web interface.
-
-
-
-
- );
- }
+ if (
+ isRouteErrorResponse(error) &&
+ isApiError(error.data) &&
+ error.data.rawData.includes("reading policy from path") &&
+ error.data.rawData.includes("no such file or directory")
+ ) {
+ return (
+
+
+
+ ACL Policy Unavailable
+
+
+
+ The ACL policy is currently unavailable because the policy file does not exist on the
+ server. This usually indicates that Headscale is running in file mode for
+ ACLs, and the specified policy file is missing.
+
+
+
+
+ In order to resolve this issue, there are two possible actions you can take:
+
+
+
+ Create the ACL policy file at the specified path in your Headscale configuration.
+
+
+ Alternatively, you can switch Headscale to use database mode for ACLs by
+ updating your Headscale configuration. This will allow Headplane to manage the ACL
+ policy directly through the web interface.
+
+
+
+
+ );
+ }
- throw error;
+ throw error;
}
diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx
index 65213ac..09f5ce8 100644
--- a/app/routes/auth/login/config-error.tsx
+++ b/app/routes/auth/login/config-error.tsx
@@ -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[]
{code.node}
))}
{" "}
-
+
Learn more
@@ -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;
+ }
}
}
diff --git a/app/routes/auth/login/page.tsx b/app/routes/auth/login/page.tsx
index 3df13c4..8e62346 100644
--- a/app/routes/auth/login/page.tsx
+++ b/app/routes/auth/login/page.tsx
@@ -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.{" "}
Learn more.
diff --git a/app/routes/dns/components/manage-ns.tsx b/app/routes/dns/components/manage-ns.tsx
index ce57fd2..3d7a69f 100644
--- a/app/routes/dns/components/manage-ns.tsx
+++ b/app/routes/dns/components/manage-ns.tsx
@@ -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;
- overrideLocalDns: boolean;
- isDisabled: boolean;
+ nameservers: Record;
+ overrideLocalDns: boolean;
+ isDisabled: boolean;
}
-export default function ManageNS({
- nameservers,
- isDisabled,
- overrideLocalDns,
-}: Props) {
- return (
-
-
Nameservers
-
- Set the nameservers used by devices on the Tailnet to resolve DNS
- queries.{' '}
-
- Learn more
-
-
-
-
-
- When enabled, use the DNS servers listed below to resolve
- names outside the tailnet. When disabled (default), devices
- will prefer their local DNS configuration.
-
- Learn More
-
-
-
-
+
+
+
+ When enabled, use the DNS servers listed below to resolve names outside the
+ tailnet. When disabled (default), devices will prefer their local DNS
+ configuration.
+
+ Learn More
+
+
+
+
Headscale supports adding custom DNS records to your Tailnet. As of now, only A{" "}
and AAAA records are supported.{" "}
-
+
Learn More
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
new file mode 100644
index 0000000..67c7649
--- /dev/null
+++ b/app/routes/home.tsx
@@ -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 (
+
+ {loaderData.linkedUserName && (
+
+
+
+ Your account is linked to Headscale user {loaderData.linkedUserName}.
+
+
+ )}
+
+ Access your network via Tailscale
+
+ You've successfully authenticated but don't have access to the dashboard. You can still
+ connect to your Headscale network by installing Tailscale.
+
+
+
+
+
+ Linux
+
+
+
+ curl -fsSL https://tailscale.com/install.sh | sh
+
+
+
+
+ Need access to the dashboard? Contact your administrator to request access.
+
+
+
+ );
+}
diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx
index d169288..e1ecb58 100644
--- a/app/routes/machines/components/machine-row.tsx
+++ b/app/routes/machines/components/machine-row.tsx
@@ -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({
{node.ipAddresses[0]}
-
@@ -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 ;
+ }
case "subnet-approved":
- case "subnet-waiting":
+ case "subnet-waiting": {
return ;
+ }
case "expired":
- case "no-expiry":
+ case "no-expiry": {
return ;
+ }
- case "tailscale-ssh":
+ case "tailscale-ssh": {
return ;
+ }
- case "headplane-agent":
+ case "headplane-agent": {
return ;
+ }
- default:
+ default: {
return null;
+ }
}
});
}
diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx
index 26edf91..8b94e3a 100644
--- a/app/routes/machines/components/menu.tsx
+++ b/app/routes/machines/components/menu.tsx
@@ -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({
)
) : undefined}
-
- {isFullButton ? (
-
-
-
diff --git a/app/routes/settings/restrictions/overview.tsx b/app/routes/settings/restrictions/overview.tsx
index 658b167..e46d916 100644
--- a/app/routes/settings/restrictions/overview.tsx
+++ b/app/routes/settings/restrictions/overview.tsx
@@ -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.{" "}
-
+
Learn More
diff --git a/app/routes/users/components/manage-banner.tsx b/app/routes/users/components/manage-banner.tsx
index 2af9404..a2e2894 100644
--- a/app/routes/users/components/manage-banner.tsx
+++ b/app/routes/users/components/manage-banner.tsx
@@ -21,28 +21,18 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{" "}
-
+
OpenID Connect provider
{". "}
Groups and user information do not automatically sync.{" "}
-
- Learn more
-
+ Learn more
>
) : (
<>
Users are not managed externally. Using OpenID Connect can create a better
experience when using Headscale.{" "}
-
- Learn more
-
+ Learn more
>
)}
diff --git a/app/routes/users/components/menu.tsx b/app/routes/users/components/menu.tsx
index 0b83e0d..14f3261 100644
--- a/app/routes/users/components/menu.tsx
+++ b/app/routes/users/components/menu.tsx
@@ -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
/>
)}
-
-
+
+
-
- setModal(key as Modal)}>
-
- Rename user
- Change role
- Link Headscale user
-
-
Delete
-
-
-
+
+
+
+
+
+
+
+
>
);
diff --git a/app/routes/users/dialogs/reassign-user.tsx b/app/routes/users/dialogs/reassign-user.tsx
index 82fa571..308cd99 100644
--- a/app/routes/users/dialogs/reassign-user.tsx
+++ b/app/routes/users/dialogs/reassign-user.tsx
@@ -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 (
-
-
-
- Change role for {user.name || user.displayName}?
-
-
- 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.{' '}
-
- Learn More
-
-
- {user.headplaneRole === 'owner' ? (
- The Tailnet owner cannot be reassigned.
- ) : (
- <>
-
-
-
- {Object.keys(Roles)
- .filter((role) => role !== 'owner')
- .map((role) => {
- const { name, desc } = mapRoleToName(role);
- return (
-
-
-
{name}
-
{desc}
-
-
- );
- })}
-
- >
- )}
-
-
- );
+export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
+ return (
+
+
+ Change role for {user.name || user.displayName}?
+
+ 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.{" "}
+
+ Learn More
+
+
+ {user.headplaneRole === "owner" ? (
+ The Tailnet owner cannot be reassigned.
+ ) : (
+ <>
+
+
+
+ {Object.keys(Roles)
+ .filter((role) => role !== "owner")
+ .map((role) => {
+ const { name, desc } = mapRoleToName(role);
+ return (
+
+
+
{name}
+
{desc}
+
+
+ );
+ })}
+
+ >
+ )}
+
+
+ );
}
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",
+ };
+ }
}
diff --git a/app/routes/users/onboarding.tsx b/app/routes/users/onboarding.tsx
index a59445d..6278926 100644
--- a/app/routes/users/onboarding.tsx
+++ b/app/routes/users/onboarding.tsx
@@ -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({
Click this button to copy the command.{" "}
View script source
@@ -270,14 +275,10 @@ export default function Page({
Requires macOS Big Sur 11.0 or later.
You can also download Tailscale on the{" "}
-
+
macOS App Store
- {"."}
+ .