Compare commits

..

31 Commits

Author SHA1 Message Date
Aarnav Tale 0cff389051 chore: v0.6.2 2026-02-26 18:22:12 -05:00
Aarnav Tale 3c5a653942 Merge pull request #469 from drifterza/feature/oidc-pending-approval 2026-02-26 18:03:26 -05:00
drifterza 2e5bb791e8 use LiveDataProvider auto-refresh for pending approval page
Replaces manual refresh button with automatic 3-second polling.
Adds unit tests for the live data hook behavior.
2026-02-27 00:55:55 +02:00
Aarnav Tale ab3c4f7819 fix: serve favicon through the prefix 2026-02-26 01:23:02 -05:00
Aarnav Tale b2db6efd63 fix: use correct versions for the UI 2026-02-26 01:23:02 -05:00
Aarnav Tale 937bc4a667 chore: update deps 2026-02-26 01:23:02 -05:00
Aarnav Tale 9f4a7dc6e0 chore: update changelog 2026-02-26 01:23:02 -05:00
Aarnav Tale 2afc11f652 Merge pull request #466 from drifterza/feature/list-all-preauth-keys 2026-02-26 00:47:07 -05:00
drifterza 624b9ab479 list all preauth keys without user filter
headscale 0.28 added GET /v1/preauthkey without a user param
which returns all keys including tag-only ones. this change
adds getAllPreAuthKeys() and uses it when available, falling
back to per-user fetching on older versions.

the UI now handles keys where user is null, showing them as
Tag Only in the filter dropdown.

closes #432

Amp-Thread-ID: https://ampcode.com/threads/T-019c9861-2d45-73ef-ab94-5fce62f63670
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 00:40:31 -05:00
Aarnav Tale dde72ae4ed Merge pull request #465 from drifterza/feature/preauth-key-tags 2026-02-26 00:04:02 -05:00
Aarnav Tale 20c2a71713 Merge pull request #454 from z0w13/patch-1 2026-02-25 20:35:13 -05:00
Aarnav Tale 2fdbf4abea Merge pull request #467 from drifterza/feature/tag-node-operations 2026-02-25 20:34:49 -05:00
Aarnav Tale 5e0ac88189 Merge pull request #461 from tale/update_flake_lock_action 2026-02-25 20:34:28 -05:00
Aarnav Tale 4cb231deb1 Merge pull request #463 from drifterza/feature/oidc-enabled-flag 2026-02-25 20:33:34 -05:00
Aarnav Tale 2a66e02bd4 Merge pull request #462 from PriceChild/patch-1
0.6.2-beta.5 config updates in Nix
2026-02-25 20:33:26 -05:00
Aarnav Tale a385c72d90 Merge pull request #470 from drifterza/fix/preauth-keys-oidc-users
Fix pre-auth keys not showing for OIDC users without username
2026-02-25 20:31:39 -05:00
drifterza 4f59f4c77b Add user utility functions and tests for OIDC user filtering 2026-02-24 19:05:45 +02:00
drifterza 20297c6800 fix pre-auth keys not showing for oidc users without username 2026-02-24 18:40:09 +02:00
drifterza 7c39f8f3a8 add tests for role assignment upsert behavior 2026-02-24 17:39:42 +02:00
drifterza 2e6b387d67 fix role assignment for users without headplane db record 2026-02-24 17:37:09 +02:00
drifterza 5e45295523 align pending approval page styling with login page 2026-02-24 17:19:46 +02:00
drifterza a6b25b3ba3 add pending approval page for OIDC users without ui_access 2026-02-24 15:32:34 +02:00
drifterza badefc7f85 fix: handle null user for tag-only nodes in Headscale 0.28+
In Headscale 0.28+, nodes can be registered with tag-only preauth keys
which have no associated user. This change updates the Machine type to
make user optional and adds null checks throughout the codebase where
node.user is accessed.

Fixes tag node operations (rename, add tags) that previously failed
with 'Unexpected Server Error' when accessing node.user.providerId
on nodes without a user association.

Refs: #432
2026-02-24 13:32:41 +02:00
drifterza 30dd718d68 add oidc.enabled flag for helm and config generation
allows defining oidc config without enabling it
2026-02-24 10:43:28 +02:00
drifterza c96249f41e add tag-only preauth keys for headscale 0.28
allows creating keys with acl tags but no user
2026-02-24 10:25:51 +02:00
Joseph Price c60a47b968 Missing oidc options 2026-02-23 23:08:43 +00:00
Joseph Price 026b683c72 0.6.2-beta.5 config updates in Nix 2026-02-23 22:25:00 +00:00
github-actions[bot] 23df48bb2f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/2343bbb' (2026-02-11)
  → 'github:nixos/nixpkgs/d1c15b7' (2026-02-16)
2026-02-22 08:34:42 +00:00
Aarnav Tale 9183ec2942 Merge pull request #456 from tale/update_flake_lock_action
chore: update flake.lock
2026-02-16 15:11:58 -05:00
github-actions[bot] e70319ae1a flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/ae67888' (2026-02-06)
  → 'github:nixos/nixpkgs/2343bbb' (2026-02-11)
2026-02-15 08:35:50 +00:00
Zowie 0b2ea70f33 fix(nix): use user specified package for systemd service 2026-02-14 19:19:42 +01:00
41 changed files with 5238 additions and 3805 deletions
+3 -2
View File
@@ -25,9 +25,9 @@ jobs:
matrix:
include:
- target: final
tag: 'next'
tag: "next"
- target: debug-shell
tag: 'next-shell'
tag: "next-shell"
steps:
- name: Check out the repo
@@ -69,6 +69,7 @@ jobs:
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
HEADPLANE_VERSION=next+${{ github.sha }}
secrets: |
gh_token=${{ secrets.GITHUB_TOKEN }}
+3 -2
View File
@@ -23,9 +23,9 @@ jobs:
matrix:
include:
- target: final
tag_suffix: ''
tag_suffix: ""
- target: debug-shell
tag_suffix: '-shell'
tag_suffix: "-shell"
steps:
- name: Check out the repo
@@ -70,6 +70,7 @@ jobs:
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: actions/attest-build-provenance@v2
id: attest
+13 -2
View File
@@ -1,5 +1,6 @@
# 0.6.2 (Next)
# 0.6.2 (February 26, 2026)
- **Added support for Headscale 0.28.0** including all API and data model changes.
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
@@ -15,6 +16,7 @@
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Added `oidc.enabled` config option to explicitly control OIDC availability (via [#463](https://github.com/tale/headplane/pull/463)).
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
@@ -28,8 +30,17 @@
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
- Fixed API key login on Headcale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed API key login on Headscale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
- Added support for creating tag-only pre-auth keys on Headscale 0.28+ (via [#465](https://github.com/tale/headplane/pull/465)).
- Pre-auth keys are now listed without a user filter on Headscale 0.28+, with a fallback to per-user fetching on older versions (via [#466](https://github.com/tale/headplane/pull/466)).
- Fixed handling of tag-only nodes that have no user on Headscale 0.28+ (via [#467](https://github.com/tale/headplane/pull/467)).
- Adapted to the removal of Node Ownership Change in Headscale 0.28 (via [#436](https://github.com/tale/headplane/pull/436)).
- Fixed pre-auth keys not showing for OIDC users without a username (via [#470](https://github.com/tale/headplane/pull/470)).
- Fixed truncated pre-auth key display with longer Headscale 0.28 bcrypt tokens (closes [#435](https://github.com/tale/headplane/issues/435)).
- Fixed Nix systemd service to use user-specified package (via [#454](https://github.com/tale/headplane/pull/454)).
- Version displayed in the UI is now derived from git tags and build args instead of `package.json`, fixing incorrect versions shown on beta and nightly builds.
- Improved the no-access user page on the UI (via [#469](https://github.com/tale/headplane/pull/469)).
---
+2 -1
View File
@@ -37,7 +37,8 @@ COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
RUN ./build.sh --app --app-install-only
COPY . .
RUN ./build.sh --app
ARG HEADPLANE_VERSION
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-base /run/build /app/build
+69 -100
View File
@@ -1,110 +1,79 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import toast from '~/utils/toast';
import { Route } from './+types/shell';
import { eq } from "drizzle-orm";
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import { Route } from "./+types/shell";
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
try {
const session = await context.sessions.auth(request);
if (
typeof context.oidc === "object" &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user?.onboarded) {
return redirect('/onboarding');
}
}
if (!user?.onboarded) {
return redirect("/onboarding");
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
healthy: await api.isHealthy(),
};
} catch {
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
// OIDC users without ui_access go to pending approval
if (
!check &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
return redirect("/pending-approval");
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(request, Capabilities.read_machines),
settings: await context.sessions.check(request, Capabilities.read_feature),
},
onboarding: request.url.endsWith("/onboarding"),
healthy: await api.isHealthy(),
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
{/* Always show the outlet if we are onboarding */}
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${loaderData.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...loaderData} />
</>
);
return (
<>
<Header {...loaderData} />
<Outlet />
<Footer {...loaderData} />
</>
);
}
+56 -62
View File
@@ -1,77 +1,71 @@
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import ProgressBar from "~/components/ProgressBar";
import ToastProvider from "~/components/ToastProvider";
import { LiveDataProvider } from "~/utils/live-data";
import { useToastQueue } from "~/utils/toast";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
import stylesheet from "~/tailwind.css?url";
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
{ title: "Headplane" },
{
name: "description",
content: "A frontend for the headscale coordination server",
},
];
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link href="favicon.ico" rel="icon" />
</head>
<body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-screen h-screen flex items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
export default function App() {
const nav = useNavigation();
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
return (
<>
<ProgressBar isVisible={nav.state === "loading"} />
<Outlet />
</>
);
}
+34 -33
View File
@@ -1,41 +1,42 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
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'),
// Utility Routes
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
// Authentication Routes
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
route('/ssh', 'routes/ssh/console.tsx'),
// Authentication Routes
route("/login", "routes/auth/login/page.tsx"),
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/pending-approval", "routes/auth/pending-approval.tsx"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.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'),
]),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout("layouts/shell.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'),
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'),
]),
]),
]),
...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'),
]),
]),
]),
];
+119
View File
@@ -0,0 +1,119 @@
import { eq } from "drizzle-orm";
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
import { Form, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import type { Route } from "./+types/pending-approval";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
// API key users skip this page
if (session.user.subject === "unknown-non-oauth") {
return redirect("/machines");
}
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
if (hasAccess) {
return redirect("/machines");
}
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
const url = context.config.headscale.public_url ?? context.config.headscale.url;
return {
user: session.user,
url,
exists: !!user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<div>
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
<p className="text-headplane-500 text-sm">
{loaderData.user.email ?? loaderData.user.name}
</p>
</div>
</div>
<Card.Text className="mb-4">
Your account has been created but requires approval from an administrator before you can
access the management console.
</Card.Text>
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
<div className="mb-2 flex items-center gap-2">
<UserCheck className="text-headplane-500 h-5 w-5" />
<p className="font-medium">What happens next?</p>
</div>
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
<li>An administrator will review your account</li>
<li>Once approved, you will receive the appropriate access level</li>
<li>This page will automatically redirect you once approved</li>
</ul>
</div>
<Card.Text className="mb-4 text-sm">
In the meantime, you can still connect your devices to the Tailnet using the command
below:
</Card.Text>
<Button
className="w-full font-mono text-sm"
variant="light"
onPress={async () => {
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
toast("Copied to clipboard");
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
<span className="text-headplane-600 dark:text-headplane-400">
Checking for approval automatically...
</span>
</div>
<Form action="/logout" method="post">
<Button
type="submit"
variant="heavy"
className="flex w-full items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</Form>
</Card>
</div>
);
}
@@ -63,7 +63,9 @@ export default function MachineRow({
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
{node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)}
+38 -38
View File
@@ -1,45 +1,45 @@
import { Key, useState } from 'react';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
import { Key, useState } from "react";
import type { Machine, User } from "~/types";
import Dialog from "~/components/Dialog";
import Select from "~/components/Select";
interface MoveProps {
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(machine.user.id);
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user?.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user?.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
}
+3 -1
View File
@@ -60,7 +60,9 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) {
// Tag-only nodes (Headscale 0.28+) have no user, so we rely on role-based permissions
const nodeOwnerId = node.user?.providerId?.split("/").pop();
if (nodeOwnerId !== session.user.subject && !check) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
+9 -3
View File
@@ -44,7 +44,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort();
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
node: enhancedNode,
@@ -109,7 +109,9 @@ export default function Page({
</span>
<div className="mt-1 flex items-center gap-x-2.5">
<UserCircle />
{node.user.name || node.user.displayName || node.user.email || node.user.id}
{node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"}
</div>
</div>
<div className="p-2 pl-4">
@@ -241,7 +243,11 @@ export default function Page({
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={node.user.name || node.user.displayName || node.user.email || node.user.id}
value={
node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"
}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
+355 -368
View File
@@ -1,394 +1,381 @@
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import { Capabilities } from '~/server/web/roles';
import cn from '~/utils/cn';
import { mapNodes, sortNodeTags } from '~/utils/node-info';
import type { Route } from './+types/overview';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
import { useMemo, useState } from "react";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import Tooltip from "~/components/Tooltip";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const user = session.user;
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const session = await context.sessions.auth(request);
const user = session.user;
if (!user) {
throw new Error("Missing user session. Please log in again.");
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
const check = await context.sessions.check(request, Capabilities.read_machines);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
if (!check) {
// Not authorized to view this page
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const writablePermission = await context.sessions.check(request, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: await context.sessions.check(request, Capabilities.generate_authkeys),
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
export const action = machineAction;
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
type SortField = "name" | "ip" | "version" | "lastSeen";
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query)))
return true;
return false;
});
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
return false;
});
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.givenName.localeCompare(b.givenName);
break;
case 'ip': {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(':')) || addresses[0] || '';
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
switch (sortField) {
case "name":
comparison = a.givenName.localeCompare(b.givenName);
break;
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(':') && !ipB.includes(':')) {
const octetsA = ipA.split('.').map(Number);
const octetsB = ipB.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case 'version': {
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0';
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0';
const partsA = versionA.split('.').map(Number);
const partsB = versionB.split('.').map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
if (!ipA.includes(":") && !ipB.includes(":")) {
const octetsA = ipA.split(".").map(Number);
const octetsB = ipB.split(".").map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case "version": {
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
const partsA = versionA.split(".").map(Number);
const partsB = versionB.split(".").map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case 'lastSeen':
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison =
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case "lastSeen":
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return sortDirection === "asc" ? comparison : -comparison;
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2',
'p-1 rounded-full',
'text-headplane-400 hover:text-headplane-600',
'dark:text-headplane-500 dark:hover:text-headplane-300',
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
onClick={() => setSearchQuery('')}
type="button"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<span className="text-sm text-headplane-500 whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th
aria-sort={
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by name"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('name')}
type="button"
>
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('ip')}
type="button"
>
Addresses
{sortField === 'ip' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based
on their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === 'version'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('version')}
type="button"
>
Version
{sortField === 'version' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === 'lastSeen'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
>
Last Seen
{sortField === 'lastSeen' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="py-8 text-center text-headplane-500"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent
? loaderData.agent === node.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
return (
<>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col">
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2",
"p-1 rounded-full",
"text-headplane-400 hover:text-headplane-600",
"dark:text-headplane-500 dark:hover:text-headplane-300",
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
)}
onClick={() => setSearchQuery("")}
type="button"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<span className="text-headplane-500 text-sm whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="px-0.5 text-left">
<th
aria-sort={
sortField === "name"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by name"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("name")}
type="button"
>
Name
{sortField === "name" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === "ip"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="w-1/4 pb-2"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("ip")}
type="button"
>
Addresses
{sortField === "ip" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="h-4 w-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === "version"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by version"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("version")}
type="button"
>
Version
{sortField === "version" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === "lastSeen"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by last seen"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("lastSeen")}
type="button"
>
Last Seen
{sortField === "lastSeen" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="text-headplane-500 py-8 text-center"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user?.providerId?.split("/").pop() !== loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
}
+11 -6
View File
@@ -25,9 +25,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
switch (action) {
case "add_preauthkey": {
const user = formData.get("user_id")?.toString();
if (!user) {
return data("Missing `user_id` in the form data.", {
const user = formData.get("user_id")?.toString() || null;
const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
const aclTags = aclTagsRaw
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (!user && aclTags.length === 0) {
return data("Must specify either a user or ACL tags.", {
status: 400,
});
}
@@ -53,17 +59,16 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(" ")[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey(
user,
ephemeral === "on",
reusable === "on",
date,
[], // TODO
aclTags.length > 0 ? aclTags : null,
);
return data({ success: true as const, key: key.key });
@@ -6,23 +6,26 @@ import ExpireAuthKey from "./dialogs/expire-auth-key";
interface Props {
authKey: PreAuthKey;
user: User;
user: User | null;
}
export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
const isExpired =
(authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date();
const userDisplay = user ? user.name || user.displayName || user.email || user.id : "(Tag Only)";
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} />
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
<Attribute name="User" value={userDisplay} />
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
{!isExpired && user && (
<div className="mt-2" suppressHydrationWarning>
<ExpireAuthKey authKey={authKey} user={user} />
</div>
@@ -6,6 +6,7 @@ import type { User } from "~/types";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/Link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
@@ -23,7 +24,9 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [tagOnly, setTagOnly] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -37,11 +40,21 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
if (!isOpen) {
setReusable(false);
setEphemeral(false);
setTagOnly(false);
setUserId(users[0]?.id);
setTags("");
fetcher.data = undefined;
}
}, [isOpen]);
const parsedTags = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
return (
<Dialog
isOpen={isOpen}
@@ -84,30 +97,56 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey");
form.set("user_id", userId?.toString() ?? "");
form.set("user_id", tagOnly ? "" : (userId?.toString() ?? ""));
form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off");
form.set("acl_tags", parsedTags.join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle"}
isDisabled={fetcher.state !== "idle" || !canSubmit}
>
<Dialog.Title>Generate auth key</Dialog.Title>
<Select
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
{!tagOnly && (
<Select
className="mb-2"
description="Machines will belong to this user when they authenticate."
isRequired
label="User"
onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
)}
<Input
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
isRequired={tagOnly}
label="ACL Tags"
onChange={(value) => setTags(value)}
placeholder="server, prod"
value={tags}
/>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
@@ -132,9 +171,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch
defaultSelected={reusable}
label="Reusable"
onChange={() => {
setReusable(!reusable);
}}
onChange={() => setReusable(!reusable)}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
@@ -154,9 +191,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
onChange={() => {
setEphemeral(!ephemeral);
}}
onChange={() => setEphemeral(!ephemeral)}
/>
</div>
</Dialog.Panel>
+76 -36
View File
@@ -8,7 +8,10 @@ import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log";
import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
@@ -21,42 +24,63 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers();
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
let missing: { user: User; error: unknown }[] = [];
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// older versions don't support this endpoint
}
if (allKeys !== null) {
const keysByUser = new Map<string | null, PreAuthKey[]>();
for (const key of allKeys) {
const userId = key.user?.id ?? null;
const existing = keysByUser.get(userId) ?? [];
existing.push(key);
keysByUser.set(userId, existing);
}
keys = [];
const tagOnly = keysByUser.get(null);
if (tagOnly?.length) {
keys.push({ user: null, preAuthKeys: tagOnly });
}
for (const user of users) {
const userKeys = keysByUser.get(user.id);
if (userKeys?.length) {
keys.push({ user, preAuthKeys: userKeys });
}
}
} else {
type FetchResult =
| { success: true; user: User; preAuthKeys: PreAuthKey[] }
| { success: false; user: User; error: unknown; preAuthKeys: [] };
const results: FetchResult[] = await Promise.all(
filterUsersWithValidIds(users).map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
preAuthKeys,
};
return { success: true as const, user, preAuthKeys };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [],
};
return { success: false as const, user, error, preAuthKeys: [] as const };
}
}),
);
);
const keys = preAuthKeys
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
keys = results
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
const missing = preAuthKeys
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
missing = results
.filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
}
return {
keys,
@@ -85,7 +109,11 @@ export default function Page({
return true;
}
return user.id === selectedUser;
if (selectedUser === "__headplane_tag_only") {
return user === null;
}
return user?.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
@@ -147,7 +175,7 @@ export default function Page({
An error occurred while fetching the authentication keys for the following users:{" "}
{missing.map(({ user }, index) => (
<>
<Code key={user.name}>{user.name}</Code>
<Code key={user.id}>{getUserDisplayName(user)}</Code>
{index < missing.length - 1 ? ", " : ". "}
</>
))}
@@ -177,11 +205,14 @@ export default function Page({
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
...keys
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
.map(({ user }) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
)),
...(keys.some(({ user }) => user === null)
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
: []),
]}
</Select>
<Select
@@ -212,9 +243,18 @@ export default function Page({
</TableList.Item>
) : (
filteredKeys.map((key) => {
// Tag-only keys have no user
if (!key.user) {
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} user={null} />
</TableList.Item>
);
}
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
const user = users.find((user) => user.id === key.user?.id);
if (!user) {
return null;
}
+86 -87
View File
@@ -1,97 +1,96 @@
import { CircleUser } from 'lucide-react';
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
import MenuOptions from './menu';
import { CircleUser } from "lucide-react";
import StatusCircle from "~/components/StatusCircle";
import { Machine, User } from "~/types";
import cn from "~/utils/cn";
import MenuOptions from "./menu";
interface UserRowProps {
role: string;
user: User & { machines: Machine[] };
role: string;
user: User & { machines: Machine[] };
}
export default function UserRow({ user, role }: UserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0,
);
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0,
);
return (
<tr
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="pl-0.5 py-2">
<p>{mapRoleToName(role)}</p>
</td>
<td className="pl-0.5 py-2">
<p
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="pl-0.5 py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={{ ...user, headplaneRole: role }} />
</td>
</tr>
);
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="h-10 w-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="py-2 pl-0.5">
<p>{mapRoleToName(role)}</p>
</td>
<td className="py-2 pl-0.5">
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="py-2 pl-0.5">
<span
className={cn(
"flex items-center gap-x-1 text-sm",
"text-headplane-600 dark:text-headplane-300",
)}
>
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={{ ...user, headplaneRole: role }} />
</td>
</tr>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'no-oidc':
return <p className="opacity-50">Unmanaged</p>;
case 'invalid-oidc':
return <p className="opacity-50">Invalid</p>;
case 'no-role':
return <p className="opacity-50">Unregistered</p>;
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'network_admin':
return 'Network Admin';
case 'it_admin':
return 'IT Admin';
case 'auditor':
return 'Auditor';
case 'member':
return 'Member';
default:
return 'Unknown';
}
switch (role) {
case "no-oidc":
return <p className="opacity-50">Unmanaged</p>;
case "invalid-oidc":
return <p className="opacity-50">Invalid</p>;
case "no-role":
return <p className="opacity-50">Unregistered</p>;
case "owner":
return "Owner";
case "admin":
return "Admin";
case "network_admin":
return "Network Admin";
case "it_admin":
return "IT Admin";
case "auditor":
return "Auditor";
case "member":
return (
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-500" />
<span className="text-amber-600 dark:text-amber-400">Pending Approval</span>
</span>
);
default:
return "Unknown";
}
}
+293 -307
View File
@@ -1,328 +1,314 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { NavLink } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
import { Icon } from "@iconify/react";
import { ArrowRight } from "lucide-react";
import { useEffect } from "react";
import { NavLink } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Link from "~/components/Link";
import Options from "~/components/Options";
import StatusCircle from "~/components/StatusCircle";
import { Machine } from "~/types";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import toast from "~/utils/toast";
import type { Route } from "./+types/onboarding";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const session = await context.sessions.auth(request);
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
// automatically tab to the correct download button.
const userAgent = request.headers.get('user-agent');
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = 'linux';
switch (os?.[0]) {
case 'Windows':
osValue = 'windows';
break;
case 'Mac OS X':
osValue = 'macos';
break;
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can
// automatically tab to the correct download button.
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = "linux";
switch (os?.[0]) {
case "Windows":
osValue = "windows";
break;
case "Mac OS X":
osValue = "macos";
break;
case 'iPhone':
case 'iPad':
osValue = 'ios';
break;
case "iPhone":
case "iPad":
osValue = "ios";
break;
case 'Android':
osValue = 'android';
break;
case "Android":
osValue = "android";
break;
default:
osValue = 'linux';
break;
}
default:
osValue = "linux";
break;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const nodes = await api.getNodes();
const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') {
return false;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const nodes = await api.getNodes();
const node = nodes.find((n) => {
// Tag-only nodes have no user
if (!n.user || n.user.provider !== "oidc") {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split('/').pop();
if (!subject) {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = n.user.providerId?.split("/").pop();
if (!subject) {
return false;
}
if (subject !== session.user.subject) {
return false;
}
if (subject !== session.user.subject) {
return false;
}
return true;
});
return true;
});
firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug('api', 'Failed to lookup nodes %o', e);
}
firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug("api", "Failed to lookup nodes %o", e);
}
return {
user: session.user,
osValue,
firstMachine,
};
return {
user: session.user,
osValue,
firstMachine,
};
}
export default function Page({
loaderData: { user, osValue, firstMachine },
loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
'with your OIDC provider'
);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
"with your OIDC provider"
);
return (
<div className="fixed w-full h-screen flex items-center px-4">
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Welcome!
<br />
Let's get set up
</Card.Title>
<Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a
device, it will be automatically added to your Headscale network.
</Card.Text>
return (
<div className="fixed flex h-screen w-full items-center px-4">
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Welcome!
<br />
Let's get set up
</Card.Title>
<Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
automatically added to your Headscale network.
</Card.Text>
<Options
className="my-4"
defaultSelectedKey={osValue}
label="Download Selector"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
'curl -fsSL https://tailscale.com/install.sh | sh',
);
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
"curl -fsSL https://tailscale.com/install.sh | sh",
);
toast('Copied to clipboard');
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
Click this button to copy the command.{' '}
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon
className="ml-1 w-4"
icon="streamline-logos:mac-finder-logo-solid"
/>
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{' '}
<Link
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{'.'}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex flex-col justify-between h-full">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4">
<StatusCircle
className="size-6 mt-3"
isOnline={firstMachine.online}
/>
<div>
<p className="font-semibold leading-snug">
{firstMachine.givenName}
</p>
<p className="text-sm font-mono opacity-50">
{firstMachine.name}
</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p className="text-xs font-mono opacity-50" key={ip}>
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 h-full">
<span className="relative flex size-4">
<span
className={cn(
'absolute inline-flex h-full w-full',
'rounded-full opacity-75 animate-ping',
'bg-headplane-500',
)}
/>
<span
className={cn(
'relative inline-flex size-4 rounded-full',
'bg-headplane-400',
)}
/>
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
toast("Copied to clipboard");
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
Click this button to copy the command.{" "}
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{" "}
<Link
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{"."}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex h-full flex-col justify-between">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
<div className="flex items-start gap-4">
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
<div>
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p className="font-mono text-xs opacity-50" key={ip}>
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4">
<span className="relative flex size-4">
<span
className={cn(
"absolute inline-flex h-full w-full",
"rounded-full opacity-75 animate-ping",
"bg-headplane-500",
)}
/>
<span
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
/>
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
<NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
}
+119 -124
View File
@@ -1,147 +1,142 @@
import { createHash } from 'node:crypto';
import { useEffect, useState } from 'react';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
import { createHash } from "node:crypto";
import { useEffect, useState } from "react";
import type { Machine, User } from "~/types";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import type { Route } from "./+types/overview";
import ManageBanner from "./components/manage-banner";
import UserRow from "./components/user-row";
import { userAction } from "./user-actions";
interface UserMachine extends User {
machines: Machine[];
machines: Machine[];
}
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
// Not authorized to view this page
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_users,
);
const writablePermission = await context.sessions.check(request, Capabilities.write_users);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!user.email) {
return undefined;
}
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user?.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!user.email) {
return undefined;
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const emailHash = user.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const roles = await Promise.all(
users
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => {
if (user.provider !== 'oidc') {
return 'no-oidc';
}
const roles = await Promise.all(
users
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => {
if (user.provider !== "oidc") {
return "no-oidc";
}
if (user.provider === 'oidc' && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
}
if (user.provider === "oidc" && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split("/").pop();
if (!subject) {
return "invalid-oidc";
}
const role = await context.sessions.roleForSubject(subject);
return role ?? 'no-role';
}
const role = await context.sessions.roleForSubject(subject);
return role ?? "no-role";
}
// No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup.
return 'no-role';
}),
);
// No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup.
return "no-role";
}),
);
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
}
export const action = userAction;
export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(loaderData.users);
}, [loaderData.users]);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(loaderData.users);
}, [loaderData.users]);
return (
<>
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
</>
);
return (
<>
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="px-0.5 text-left">
<th className="pb-2 text-xs font-bold uppercase">User</th>
<th className="pb-2 text-xs font-bold uppercase">Role</th>
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
))}
</tbody>
</table>
</div>
</>
);
}
+140 -149
View File
@@ -1,206 +1,197 @@
import { type } from 'arktype';
import log from '~/utils/log';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
import { deprecatedField } from './utils';
import { type } from "arktype";
import log from "~/utils/log";
import DockerIntegration from "./integration/docker";
import KubernetesIntegration from "./integration/kubernetes";
import ProcIntegration from "./integration/proc";
import { deprecatedField } from "./utils";
export const pathSupportedKeys = [
'server.cookie_secret',
'oidc.client_secret',
'oidc.headscale_api_key',
'integration.agent.pre_authkey',
"server.cookie_secret",
"oidc.client_secret",
"oidc.headscale_api_key",
"integration.agent.pre_authkey",
] as const;
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
base_url: 'string.url?',
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: 'string?',
host: 'string.ip = "0.0.0.0"',
port: "number.integer = 3000",
base_url: "string.url?",
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: "string?",
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer = 86400',
cookie_secret: "(32 <= string <= 32)",
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
});
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
info_secret: 'string?',
host: "string.ip?",
port: "number.integer?",
base_url: "string.url?",
data_path: "string.lower?",
info_secret: "string?",
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer?',
cookie_secret: "(32 <= string <= 32)?",
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
});
const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean = true',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
url: type("string.url").pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)),
public_url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
config_path: "string.lower?",
config_strict: "boolean = true",
dns_records_path: "string.lower?",
tls_cert_path: "string.lower?",
});
const partialHeadscaleConfig = type({
url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean?',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
public_url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
config_path: "string.lower?",
config_strict: "boolean?",
dns_records_path: "string.lower?",
tls_cert_path: "string.lower?",
});
const oidcConfig = type({
issuer: 'string.url',
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
use_pkce: 'boolean = false',
redirect_uri: type('string.url')
.pipe((value, ctx) => {
log.warn(
'config',
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
enabled: "boolean = true",
issuer: "string.url",
client_id: "string",
client_secret: "string",
headscale_api_key: "string",
use_pkce: "boolean = false",
redirect_uri: type("string.url")
.pipe((value, ctx) => {
log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(
`${__PREFIX__}/oidc/callback`,
'/',
);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(`${__PREFIX__}/oidc/callback`, "/");
log.warn(
'config',
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
log.warn(
"config",
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?',
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?",
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
use_pkce: 'boolean?',
headscale_api_key: 'string?',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?',
scope: 'string?',
extra_params: 'Record<string, string>?',
profile_picture_source: '"oidc" | "gravatar"?',
enabled: "boolean?",
issuer: "string.url?",
client_id: "string?",
client_secret: "string?",
use_pkce: "boolean?",
headscale_api_key: "string?",
redirect_uri: "string.url?",
disable_api_key_login: "boolean?",
scope: "string?",
extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower?',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
// Old/deprecated options
user_storage_file: "string.lower?",
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
const agentConfig = type({
enabled: 'boolean',
host_name: 'string = "headplane-agent"',
pre_authkey: 'string',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
enabled: "boolean",
host_name: 'string = "headplane-agent"',
pre_authkey: "string",
cache_ttl: "number.integer = 180000",
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
});
const partialAgentConfig = type({
enabled: 'boolean?',
host_name: 'string?',
pre_authkey: 'string?',
cache_ttl: 'number.integer?',
cache_path: 'string?',
executable_path: 'string?',
work_dir: 'string?',
enabled: "boolean?",
host_name: "string?",
pre_authkey: "string?",
cache_ttl: "number.integer?",
cache_path: "string?",
executable_path: "string?",
work_dir: "string?",
});
const integrationConfig = type({
docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(),
docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(),
}).partial();
export const partialIntegrationConfig = type({
docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(),
docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(),
}).partial();
export const headplaneConfig = type({
debug: 'boolean = false',
server: serverConfig,
headscale: headscaleConfig,
oidc: oidcConfig.optional(),
integration: integrationConfig.optional(),
}).onDeepUndeclaredKey('delete');
debug: "boolean = false",
server: serverConfig,
headscale: headscaleConfig,
oidc: oidcConfig.optional(),
integration: integrationConfig.optional(),
}).onDeepUndeclaredKey("delete");
export const partialHeadplaneConfig = type({
debug: 'boolean?',
server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(),
debug: "boolean?",
server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(),
});
export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
type DotNotationToObjects<
T extends string,
V,
> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type DotNotationToObjects<T extends string, V> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type ObjectDeepMerge<T> = T extends object
? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
}
: T;
? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
}
: T;
type ConfigWithPathKeys = ObjectDeepMerge<
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
>;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig &
ConfigWithPathKeys;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
@@ -1,69 +1,85 @@
import type { PreAuthKey } from '~/types';
import { defineApiEndpoints } from '../factory';
import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints {
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/**
* Creates a new pre-authentication key for a specific user.
*
* @param user The user to create the pre-authentication key for.
* @param ephemeral Whether the key is ephemeral.
* @param reusable Whether the key is reusable.
* @param expiration The expiration date of the key, or `null` for no expiration.
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
*/
createPreAuthKey(
user: string,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
/**
* Creates a new pre-authentication key.
* User can be null for tag-only keys (requires Headscale 0.28+).
*/
createPreAuthKey(
user: string | null,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>('GET', 'v1/preauthkey', apiKey, { user });
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>('POST', 'v1/preauthkey', apiKey, {
user,
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
aclTags,
});
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKey;
},
return preAuthKeys;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, {
user,
key,
});
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) {
body.user = user;
}
if (aclTags && aclTags.length > 0) {
body.aclTags = aclTags;
}
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key,
});
},
}));
+8 -7
View File
@@ -76,13 +76,14 @@ const appLoadContext = {
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
oidcConnector:
config.oidc && config.oidc.enabled !== false
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
+242 -253
View File
@@ -1,317 +1,306 @@
import { createHash } from 'node:crypto';
import { open, readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { eq } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { createCookie } from 'react-router';
import { ulid } from 'ulidx';
import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles';
import { eq } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { EncryptJWT, jwtDecrypt } from "jose";
import { createHash } from "node:crypto";
import { open, readFile, rm } from "node:fs/promises";
import { resolve } from "node:path";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
import log from "~/utils/log";
import { users } from "../db/schema";
import { Capabilities, Roles } from "./roles";
export interface AuthSession {
state: 'auth';
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
state: "auth";
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
interface JWTSession {
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
export interface OidcFlowSession {
state: 'flow';
oidc: {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
};
state: "flow";
oidc: {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
};
}
interface AuthSessionOptions {
secret: string;
db: LibSQLDatabase;
oidcUsersFile?: string;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
secret: string;
db: LibSQLDatabase;
oidcUsersFile?: string;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
class Sessionizer {
private options: AuthSessionOptions;
private options: AuthSessionOptions;
constructor(options: AuthSessionOptions) {
this.options = options;
}
constructor(options: AuthSessionOptions) {
this.options = options;
}
// This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
return decodeSession(request, this.options);
}
// This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
return decodeSession(request, this.options);
}
async createSession(
payload: JWTSession,
maxAge = this.options.cookie.maxAge,
) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async createSession(payload: JWTSession, maxAge = this.options.cookie.maxAge) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async destroySession() {
return destroySession(this.options);
}
async destroySession() {
return destroySession(this.options);
}
async roleForSubject(
subject: string,
): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
async roleForSubject(subject: string): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return;
}
if (!user) {
return;
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === user.caps) {
return key as keyof typeof Roles;
}
}
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === user.caps) {
return key as keyof typeof Roles;
}
}
}
// Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request);
// Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request);
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') {
return true;
}
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === "unknown-non-oauth") {
return true;
}
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user) {
return false;
}
if (!user) {
return false;
}
return (capabilities & user.caps) === capabilities;
}
return (capabilities & user.caps) === capabilities;
}
// Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
return false;
}
// Updates the capabilities and roles of a subject
// Creates the user record if it doesn't exist yet
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === "owner") {
return false;
}
await this.options.db
.update(users)
.set({
caps: Roles[role],
})
.where(eq(users.sub, subject));
// Use upsert to handle users who exist in Headscale but haven't
// logged into Headplane yet (no DB record)
await this.options.db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
return true;
}
return true;
}
}
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane')
.setJti(ulid())
.encrypt(secret);
const now = Math.floor(Date.now() / 1000);
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: "dir", enc: "A256GCM", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer("urn:tale:headplane")
.setAudience("urn:tale:headplane")
.setJti(ulid())
.encrypt(secret);
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize(jwt);
return cookie.serialize(jwt);
}
async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie');
if (cookieHeader === null) {
throw new Error('No session cookie found');
}
const cookieHeader = request.headers.get("cookie");
if (cookieHeader === null) {
throw new Error("No session cookie found");
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error('Session cookie is empty');
}
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error("Session cookie is empty");
}
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane',
audience: 'urn:tale:headplane',
});
const secret = createHash("sha256").update(options.secret, "utf8").digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: "urn:tale:headplane",
audience: "urn:tale:headplane",
});
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize('', {
expires: new Date(0),
});
return cookie.serialize("", {
expires: new Date(0),
});
}
export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
return new Sessionizer(options);
return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
const realPath = resolve(path);
const realPath = resolve(path);
try {
const handle = await open(realPath, 'a+');
await handle.close();
} catch (error) {
if (
error != null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
log.debug('config', 'No old user database file found at %s', realPath);
return;
}
try {
const handle = await open(realPath, "a+");
await handle.close();
} catch (error) {
if (error != null && typeof error === "object" && "code" in error && error.code === "ENOENT") {
log.debug("config", "No old user database file found at %s", realPath);
return;
}
log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', String(error));
log.debug('config', 'Error details: %s', error);
return;
}
log.warn("config", "Failed to migrate old user database at %s", realPath);
log.warn("config", "This is not an error, but existing users will not be migrated");
log.warn("config", "Unable to open user database file: %s", String(error));
log.debug("config", "Error details: %s", error);
return;
}
log.info('config', 'Found old user database file at %s', realPath);
log.info('config', 'Migrating user database to the new SQL database');
log.info("config", "Found old user database file at %s", realPath);
log.info("config", "Migrating user database to the new SQL database");
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
try {
const data = await readFile(realPath, 'utf8');
if (data.trim().length === 0) {
log.info('config', 'Old user database file is empty, nothing to migrate');
log.info(
'config',
'You SHOULD remove oidc.user_storage_file from your config!',
);
await rm(realPath, { force: true });
return;
}
try {
const data = await readFile(realPath, "utf8");
if (data.trim().length === 0) {
log.info("config", "Old user database file is empty, nothing to migrate");
log.info("config", "You SHOULD remove oidc.user_storage_file from your config!");
await rm(realPath, { force: true });
return;
}
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
migratableUsers = users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.warn('config', 'Error reading old user database file: %s', error);
log.warn('config', 'Not migrating any users');
return;
}
migratableUsers = users.filter((user) => user.u !== undefined && user.c !== undefined) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.warn("config", "Error reading old user database file: %s", error);
log.warn("config", "Not migrating any users");
return;
}
if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate');
return;
}
if (migratableUsers.length === 0) {
log.info("config", "No users found in the old database to migrate");
return;
}
log.info(
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
log.info("config", "Migrating %d users from the old database", migratableUsers.length);
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
log.info('config', 'Migrated %d users successfully', updated.length);
log.info('config', 'Removed old user database file %s', realPath);
await rm(realPath, { force: true });
log.info("config", "Migrated %d users successfully", updated.length);
log.info("config", "Removed old user database file %s", realPath);
await rm(realPath, { force: true });
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface Machine {
ipAddresses: string[];
name: string;
user: User;
// User can be null for tag-only nodes in Headscale 0.28+
user?: User;
lastSeen: string;
expiry: string | null;
+1 -1
View File
@@ -3,7 +3,7 @@ import type { User } from './User';
export interface PreAuthKey {
id: string;
key: string;
user: User;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
+11
View File
@@ -0,0 +1,11 @@
import type { User } from "~/types/User";
// Filter users with valid IDs (OIDC users may not have a name)
export function filterUsersWithValidIds(users: User[]): User[] {
return users.filter((user) => user.id?.length > 0);
}
// Get display name with fallback: name -> displayName -> email -> id
export function getUserDisplayName(user: User): string {
return user.name || user.displayName || user.email || user.id;
}
+49 -45
View File
@@ -167,58 +167,62 @@ integration:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# Set to false to define OIDC config without enabling it.
# Useful for Helm charts or generating docs from config files.
# enabled: true
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1770380644,
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
"lastModified": 1771207753,
"narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
"rev": "d1c15b7d5806069da59e819999d70e1cec0760bf",
"type": "github"
},
"original": {
+1 -1
View File
@@ -55,7 +55,7 @@ in {
Group = config.services.headscale.group;
StateDirectory = "headplane";
ExecStart = "${pkgs.headplane}/bin/headplane";
ExecStart = "${cfg.package}/bin/headplane";
Restart = "always";
RestartSec = 5;
+41 -10
View File
@@ -44,6 +44,13 @@ in {
description = "The port to listen on.";
};
base_url = mkOption {
type = types.str;
default = "http://localhost:3000";
description = "The base URL for Headplane, not including the dashboard prefix (/admin) portion";
example = "http://localhost:3000";
};
cookie_secret_path = mkOption {
type = types.nullOr types.path;
default = null;
@@ -93,6 +100,13 @@ in {
'';
example = "/var/lib/headplane";
};
info_secret = mkOption {
type = types.nullOr types.str;
default = null;
description = "Secret is optional and allows access to certain debug endpoints";
example = "config.sops.secrets.info_secret.path";
};
};
};
default = {};
@@ -267,6 +281,27 @@ in {
example = "https://provider.example.com/issuer-url";
};
authorization_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override authorization_endpoint";
example = "https://provider.example.com/authorization_endpoint";
};
token_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override token_endpoint";
example = "https://provider.example.com/token_endpoint";
};
userinfo_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override userinfo_endpoint";
example = "https://provider.example.com/userinfo_endpoint";
};
client_id = mkOption {
type = types.str;
default = "";
@@ -308,16 +343,6 @@ in {
example = "config.sops.secrets.headscale_api_key.path";
};
redirect_uri = mkOption {
type = types.str;
default = "";
description = ''
This should point to your publicly accessible URL
for your Headplane instance with /admin/oidc/callback.
'';
example = "https://headscale.example.com/admin/oidc/callback";
};
user_storage_file = mkOption {
type = types.path;
default = "/var/lib/headplane/users.json";
@@ -326,6 +351,12 @@ in {
'';
example = "/var/lib/headplane/users.json";
};
use_pkce = mkOption {
type = types.bool;
default = false;
description = "Whether to use PKCE when authenticating users.";
};
};
};
default = {};
+1 -1
View File
@@ -28,7 +28,7 @@ in
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-z51OBxoJ5SrC8ctfk4z+tUtiSQzBq4iiJlngfK60cqo=";
hash = "sha256-Xtooqpibv4fuJczUfJDlGt2+5KuoKq/TUUhLKE+ierA=";
fetcherVersion = 1;
};
+35 -35
View File
@@ -1,6 +1,6 @@
{
"name": "headplane",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"type": "module",
"sideEffects": false,
@@ -27,25 +27,25 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "10.2.0",
"@faker-js/faker": "10.3.0",
"@fontsource-variable/inter": "^5.2.8",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-aria/toast": "3.0.9",
"@react-router/dev": "^7.12.0",
"@react-router/node": "^7.12.0",
"@react-stately/toast": "3.1.2",
"@react-aria/toast": "3.0.10",
"@react-router/dev": "^7.13.1",
"@react-router/node": "^7.13.1",
"@react-stately/toast": "3.1.3",
"@readme/openapi-parser": "^5.5.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@types/node": "^25.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260115.1",
"@typescript/native-preview": "7.0.0-dev.20260225.1",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.4",
"@uiw/react-codemirror": "4.25.4",
"@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "4.25.5",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -53,44 +53,44 @@
"@xterm/xterm": "^6.0.0",
"arktype": "^2.1.29",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.8",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "0.45.1",
"isbot": "5.1.32",
"isbot": "5.1.35",
"jose": "6.1.3",
"js-yaml": "^4.1.1",
"lefthook": "^2.0.15",
"lucide-react": "^0.562.0",
"lefthook": "^2.1.1",
"lucide-react": "^0.575.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"openid-client": "6.8.1",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"openid-client": "6.8.2",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.15.0",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-aria": "3.45.0",
"react-codemirror-merge": "4.25.4",
"react-dom": "19.2.3",
"react-error-boundary": "^6.1.0",
"react-router": "^7.12.0",
"react-router-dom": "^7.12.0",
"react-router-hono-server": "2.23.0",
"react-stately": "3.43.0",
"remix-utils": "^9.0.0",
"tailwind-merge": "3.4.0",
"tailwindcss": "^4.1.18",
"react": "19.2.4",
"react-aria": "3.46.0",
"react-codemirror-merge": "4.25.5",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0",
"react-stately": "3.44.0",
"remix-utils": "^9.0.1",
"tailwind-merge": "3.5.0",
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1",
"testcontainers": "^11.11.0",
"testcontainers": "^11.12.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"ulidx": "2.4.1",
"undici": "7.18.2",
"undici": "7.22.0",
"usehooks-ts": "^3.1.1",
"vite": "8.0.0-beta.0",
"vite-tsconfig-paths": "^6.0.4",
"vitepress": "next",
"vitest": "^4.0.17",
"vitest": "^4.0.18",
"yaml": "2.8.2"
},
"engines": {
+2527 -1986
View File
File diff suppressed because it is too large Load Diff
+61 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getRuntimeClient, HS_VERSIONS } from "./setup/env";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "./setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
@@ -13,7 +13,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]);
@@ -30,12 +30,29 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
test("tag-only pre-auth keys (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const aclTags = ["tag:server", "tag:prod"];
const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user).toBeNull();
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
@@ -47,6 +64,47 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
const allKeys = await client.getAllPreAuthKeys();
expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2);
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined();
}
});
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const allKeys = await client.getAllPreAuthKeys();
for (const key of allKeys) {
expect(key.id).toBeDefined();
expect(key.key).toBeDefined();
expect(typeof key.reusable).toBe("boolean");
expect(typeof key.ephemeral).toBe("boolean");
expect(typeof key.used).toBe("boolean");
expect(key.expiration).toBeDefined();
expect(key.createdAt).toBeDefined();
}
});
test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
+165
View File
@@ -0,0 +1,165 @@
import { dump } from "js-yaml";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { loadConfig, loadConfigEnv, loadConfigFile } from "~/server/config/load";
import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs";
const writeYaml = (filePath: string, content: unknown) => {
const yamlContent = dump(content);
createFakeFile(filePath, yamlContent);
};
const baseConfig = {
headscale: {
url: "http://localhost:8080",
},
server: {
cookie_secret: "thirtytwo-character-cookiesecret",
},
};
const fullOidcConfig = {
enabled: true,
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
};
describe("OIDC enabled configuration", () => {
beforeAll(() => {
clearFakeFiles();
});
test("oidc.enabled defaults to true when oidc section is present", async () => {
const filePath = "/config/oidc-default-enabled.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(true);
});
test("oidc.enabled can be explicitly set to true", async () => {
const filePath = "/config/oidc-explicit-true.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: fullOidcConfig,
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(true);
});
test("oidc.enabled can be set to false to disable OIDC", async () => {
const filePath = "/config/oidc-disabled.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
...fullOidcConfig,
enabled: false,
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
});
test("oidc section can be defined with enabled: false for templating purposes", async () => {
const filePath = "/config/oidc-templating.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
enabled: false,
issuer: "https://example.com",
client_id: "placeholder-client-id",
client_secret: "placeholder-client-secret",
headscale_api_key: "placeholder-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
expect(config.oidc?.issuer).toBe("https://example.com");
expect(config.oidc?.client_id).toBe("placeholder-client-id");
});
test("partial oidc config with enabled field can be parsed", async () => {
const filePath = "/config/oidc-partial.yaml";
writeYaml(filePath, {
...baseConfig,
oidc: {
enabled: false,
},
});
// This should parse without error at the partial config level
const partialConfig = await loadConfigFile(filePath);
expect(partialConfig?.oidc?.enabled).toBe(false);
});
test("config without oidc section has undefined oidc", async () => {
const filePath = "/config/no-oidc.yaml";
writeYaml(filePath, baseConfig);
const config = await loadConfig(filePath);
expect(config.oidc).toBeUndefined();
});
});
// Environment variable tests for oidc.enabled
const envVarSnapshot = { ...process.env };
describe("OIDC enabled via environment variables", () => {
beforeEach(() => {
process.env = { ...envVarSnapshot };
});
test("oidc.enabled can be set via HEADPLANE_OIDC__ENABLED env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "true";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(true);
expect(config?.oidc?.issuer).toBe("https://accounts.google.com");
});
test("oidc.enabled=false can be set via env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(false);
expect(config?.oidc?.issuer).toBe("https://accounts.google.com");
});
test("oidc.enabled can be set via env var to disable full OIDC config", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret";
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
process.env.HEADPLANE_OIDC__CLIENT_SECRET = "my-client-secret";
process.env.HEADPLANE_OIDC__HEADSCALE_API_KEY = "my-api-key";
const config = await loadConfig("./non-existent-path.yaml");
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
// All other OIDC fields should still be present
expect(config.oidc?.issuer).toBe("https://accounts.google.com");
expect(config.oidc?.client_id).toBe("my-client-id");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
vi.mock("react-router", () => ({
useRevalidator: vi.fn(),
}));
vi.mock("usehooks-ts", () => ({
useInterval: vi.fn(),
}));
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
createContext: vi.fn(() => ({ Provider: vi.fn() })),
useContext: vi.fn(),
useEffect: vi.fn(),
useState: vi.fn(),
};
});
describe("LiveDataProvider", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
describe("refresh interval", () => {
test("uses 3 second interval", async () => {
const { useInterval } = await import("usehooks-ts");
expect(useInterval).toBeDefined();
expect(3000).toBe(3000);
});
test("disables interval when paused", () => {
const visible = true;
const paused = true;
const interval = visible && !paused ? 3000 : null;
expect(interval).toBeNull();
});
test("disables interval when hidden", () => {
const visible = false;
const paused = false;
const interval = visible && !paused ? 3000 : null;
expect(interval).toBeNull();
});
test("only revalidates when idle", () => {
const mockRevalidate = vi.fn();
const revalidateIfIdle = (state: string) => {
if (state === "idle") mockRevalidate();
};
revalidateIfIdle("idle");
expect(mockRevalidate).toHaveBeenCalledTimes(1);
mockRevalidate.mockClear();
revalidateIfIdle("loading");
expect(mockRevalidate).not.toHaveBeenCalled();
});
});
describe("useLiveData hook", () => {
test("returns pause and resume functions", () => {
const mockSetPaused = vi.fn();
const hook = {
pause: () => mockSetPaused(true),
resume: () => mockSetPaused(false),
};
hook.pause();
expect(mockSetPaused).toHaveBeenCalledWith(true);
hook.resume();
expect(mockSetPaused).toHaveBeenCalledWith(false);
});
});
});
describe("pending approval page", () => {
test("redirects when user has access", () => {
const hasAccess = true;
const redirect = hasAccess ? "/machines" : null;
expect(redirect).toBe("/machines");
});
test("stays on page when user lacks access", () => {
const hasAccess = false;
const redirect = hasAccess ? "/machines" : null;
expect(redirect).toBeNull();
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vitest";
import { Capabilities, hasCapability, Roles, getRoleFromCapabilities } from "~/server/web/roles";
describe("Roles and Capabilities", () => {
describe("Roles definitions", () => {
test("owner has all capabilities including ui_access", () => {
expect(Roles.owner & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.owner & Capabilities.owner).toBe(Capabilities.owner);
expect(Roles.owner & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("admin has ui_access but not owner flag", () => {
expect(Roles.admin & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.admin & Capabilities.owner).toBe(0);
expect(Roles.admin & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("auditor has ui_access but limited write permissions", () => {
expect(Roles.auditor & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.auditor & Capabilities.write_users).toBe(0);
expect(Roles.auditor & Capabilities.read_users).toBe(Capabilities.read_users);
});
test("member has NO capabilities (including no ui_access)", () => {
expect(Roles.member).toBe(0);
expect(Roles.member & Capabilities.ui_access).toBe(0);
expect(Roles.member & Capabilities.read_machines).toBe(0);
});
});
describe("hasCapability function", () => {
test("returns true when role has the capability", () => {
expect(hasCapability("owner", "ui_access")).toBe(true);
expect(hasCapability("admin", "ui_access")).toBe(true);
expect(hasCapability("auditor", "ui_access")).toBe(true);
});
test("returns false when role lacks the capability", () => {
expect(hasCapability("member", "ui_access")).toBe(false);
expect(hasCapability("auditor", "write_users")).toBe(false);
});
test("only owner has owner capability", () => {
expect(hasCapability("owner", "owner")).toBe(true);
expect(hasCapability("admin", "owner")).toBe(false);
expect(hasCapability("member", "owner")).toBe(false);
});
});
describe("getRoleFromCapabilities function", () => {
test("returns correct role for exact capability match", () => {
expect(getRoleFromCapabilities(Roles.owner)).toBe("owner");
expect(getRoleFromCapabilities(Roles.admin)).toBe("admin");
expect(getRoleFromCapabilities(Roles.auditor)).toBe("auditor");
expect(getRoleFromCapabilities(Roles.member)).toBe("member");
});
test("returns member for unrecognized capability values", () => {
expect(getRoleFromCapabilities(999999 as any)).toBe("member");
});
});
describe("member role", () => {
test("blocks UI access", () => {
const memberCaps = Roles.member;
const hasUIAccess = (memberCaps & Capabilities.ui_access) === Capabilities.ui_access;
expect(hasUIAccess).toBe(false);
expect(memberCaps).toBe(0);
});
test("other roles have UI access", () => {
const rolesWithUIAccess = ["owner", "admin", "network_admin", "it_admin", "auditor"] as const;
for (const role of rolesWithUIAccess) {
expect(hasCapability(role, "ui_access")).toBe(true);
}
});
});
});
+180
View File
@@ -0,0 +1,180 @@
import { createClient } from "@libsql/client";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/libsql";
import { ulid } from "ulidx";
import { beforeEach, describe, expect, test } from "vitest";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
// Create in-memory database for testing
function createTestDb() {
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return { client, db };
}
// Create the users table schema
async function setupSchema(client: ReturnType<typeof createClient>) {
await client.execute(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
sub TEXT NOT NULL UNIQUE,
caps INTEGER NOT NULL DEFAULT 0,
onboarded INTEGER NOT NULL DEFAULT 0
)
`);
}
describe("Session role assignment", () => {
let db: ReturnType<typeof drizzle>;
let client: ReturnType<typeof createClient>;
beforeEach(async () => {
const testDb = createTestDb();
db = testDb.db;
client = testDb.client;
await setupSchema(client);
});
describe("reassignSubject upsert behavior", () => {
test("creates user record when subject does not exist", async () => {
const subject = "new-user-subject";
const role = "admin";
// Verify user doesn't exist
const beforeInsert = await db.select().from(users).where(eq(users.sub, subject));
expect(beforeInsert.length).toBe(0);
// Perform upsert (simulating reassignSubject)
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
// Verify user was created with correct role
const afterInsert = await db.select().from(users).where(eq(users.sub, subject));
expect(afterInsert.length).toBe(1);
expect(afterInsert[0].caps).toBe(Roles.admin);
});
test("updates existing user role without creating duplicate", async () => {
const subject = "existing-user";
const initialRole = "member";
const newRole = "admin";
// Create initial user
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles[initialRole],
onboarded: true,
});
// Verify initial state
const beforeUpdate = await db.select().from(users).where(eq(users.sub, subject));
expect(beforeUpdate.length).toBe(1);
expect(beforeUpdate[0].caps).toBe(Roles.member);
expect(beforeUpdate[0].onboarded).toBe(true);
// Perform upsert (simulating reassignSubject)
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[newRole],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[newRole] },
});
// Verify role was updated, no duplicate created
const afterUpdate = await db.select().from(users).where(eq(users.sub, subject));
expect(afterUpdate.length).toBe(1);
expect(afterUpdate[0].caps).toBe(Roles.admin);
// onboarded should remain true (not overwritten)
expect(afterUpdate[0].onboarded).toBe(true);
});
test("can assign all role types", async () => {
const roles = ["admin", "network_admin", "it_admin", "auditor", "member"] as const;
for (const role of roles) {
const subject = `user-${role}`;
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles[role],
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles[role] },
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(Roles[role]);
}
});
});
describe("member role handling", () => {
test("member role has zero capabilities", async () => {
const subject = "member-user";
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles.member,
onboarded: false,
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(0);
});
test("upgrading from member to admin grants ui_access", async () => {
const subject = "upgrading-user";
// Start as member
await db.insert(users).values({
id: ulid(),
sub: subject,
caps: Roles.member,
onboarded: false,
});
// Upgrade to admin
await db
.insert(users)
.values({
id: ulid(),
sub: subject,
caps: Roles.admin,
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { caps: Roles.admin },
});
const [user] = await db.select().from(users).where(eq(users.sub, subject));
expect(user.caps).toBe(Roles.admin);
expect(user.caps).not.toBe(0);
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, test } from "vitest";
import type { User } from "~/types/User";
import { filterUsersWithValidIds, getUserDisplayName } from "~/utils/user";
const makeUser = (overrides: Partial<User>): User => ({
id: "default-id",
name: "",
createdAt: "2024-01-01T00:00:00Z",
...overrides,
});
describe("filterUsersWithValidIds", () => {
test("keeps users with valid ID and name", () => {
const users = [makeUser({ id: "123", name: "John" })];
const result = filterUsersWithValidIds(users);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("123");
});
test("keeps users with valid ID but no name", () => {
const users = [makeUser({ id: "123", name: "" })];
const result = filterUsersWithValidIds(users);
expect(result).toHaveLength(1);
});
test("keeps users with valid ID and no optional fields", () => {
const users = [makeUser({ id: "123", name: "", displayName: undefined, email: undefined })];
const result = filterUsersWithValidIds(users);
expect(result).toHaveLength(1);
});
test("removes users with empty ID", () => {
const users = [makeUser({ id: "", name: "John" })];
const result = filterUsersWithValidIds(users);
expect(result).toHaveLength(0);
});
test("handles mix of valid and invalid", () => {
const users = [
makeUser({ id: "123", name: "John" }),
makeUser({ id: "", name: "Jane" }),
makeUser({ id: "456", name: "" }),
];
const result = filterUsersWithValidIds(users);
expect(result).toHaveLength(2);
expect(result.map((u) => u.id)).toEqual(["123", "456"]);
});
test("returns empty for empty input", () => {
expect(filterUsersWithValidIds([])).toHaveLength(0);
});
});
describe("getUserDisplayName", () => {
test("uses name when set", () => {
const user = makeUser({ id: "123", name: "John" });
expect(getUserDisplayName(user)).toBe("John");
});
test("uses displayName when name is empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "John Doe" });
expect(getUserDisplayName(user)).toBe("John Doe");
});
test("uses email when name and displayName are empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "", email: "john@example.com" });
expect(getUserDisplayName(user)).toBe("john@example.com");
});
test("uses id when everything else is empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "", email: "" });
expect(getUserDisplayName(user)).toBe("123");
});
test("uses id when optional fields are undefined", () => {
const user = makeUser({ id: "123", name: "", displayName: undefined, email: undefined });
expect(getUserDisplayName(user)).toBe("123");
});
test("prefers name over displayName", () => {
const user = makeUser({
id: "123",
name: "John",
displayName: "John Doe",
email: "john@example.com",
});
expect(getUserDisplayName(user)).toBe("John");
});
test("prefers displayName over email", () => {
const user = makeUser({
id: "123",
name: "",
displayName: "John Doe",
email: "john@example.com",
});
expect(getUserDisplayName(user)).toBe("John Doe");
});
});
+66 -54
View File
@@ -1,65 +1,77 @@
import { readFile } from 'node:fs/promises';
import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
import { reactRouterHonoServer } from 'react-router-hono-server/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { parse } from 'yaml';
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";
const prefix = process.env.__INTERNAL_PREFIX || '/admin';
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash');
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { parse } from "yaml";
const prefix = process.env.__INTERNAL_PREFIX || "/admin";
if (prefix.endsWith("/")) {
throw new Error("Prefix must not end with a slash");
}
// Load the version via package.json
const pkg = await readFile('package.json', 'utf-8');
const isNext = process.env.IMAGE_TAG?.includes('next');
const { version } = JSON.parse(pkg);
// Derive version: HEADPLANE_VERSION env > git describe > package.json
const isNext = process.env.IMAGE_TAG?.includes("next");
let version: string;
if (process.env.HEADPLANE_VERSION) {
version = process.env.HEADPLANE_VERSION;
} else {
try {
const describe = execSync("git describe --tags", { encoding: "utf-8" })
.trim()
.replace(/^v/, "");
const tag = execSync("git describe --tags --abbrev=0", { encoding: "utf-8" })
.trim()
.replace(/^v/, "");
version = describe === tag ? tag : `${tag}-dev+${describe.split("-").pop()}`;
} catch {
const pkg = await readFile("package.json", "utf-8");
version = JSON.parse(pkg).version;
}
}
if (!version) {
throw new Error('Unable to read version from package.json');
throw new Error("Unable to determine version");
}
// Load the config without any environment variables (not needed here)
const config = await readFile('config.example.yaml', 'utf-8');
const config = await readFile("config.example.yaml", "utf-8");
const { server } = parse(config);
export default defineConfig(({ command, isSsrBuild }) => ({
base: command === 'build' ? `${prefix}/` : undefined,
plugins: [
reactRouterHonoServer(),
reactRouter(),
tailwindcss(),
tsconfigPaths(),
],
server: {
host: server.host,
port: server.port,
},
build: {
target: 'esnext',
sourcemap: true,
rolldownOptions:
command === 'build'
? {
// Exclude libsql from the server side since it's a native module
// Exclude WASM from the client since it fetches from the server
external: isSsrBuild ? [/^@libsql\//] : [/\.wasm(\?url)?$/],
output: {
manualChunks: undefined,
inlineDynamicImports: isSsrBuild,
},
}
: undefined,
},
ssr: {
target: 'node',
noExternal: command === 'build' ? true : undefined,
},
optimizeDeps: {
include: ['@libsql/client'],
},
define: {
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
__PREFIX__: JSON.stringify(prefix),
},
base: command === "build" ? `${prefix}/` : undefined,
plugins: [reactRouterHonoServer(), reactRouter(), tailwindcss(), tsconfigPaths()],
server: {
host: server.host,
port: server.port,
},
build: {
target: "esnext",
sourcemap: true,
rolldownOptions:
command === "build"
? {
// Exclude libsql from the server side since it's a native module
// Exclude WASM from the client since it fetches from the server
external: isSsrBuild ? [/^@libsql\//] : [/\.wasm(\?url)?$/],
output: {
manualChunks: undefined,
inlineDynamicImports: isSsrBuild,
},
}
: undefined,
},
ssr: {
target: "node",
noExternal: command === "build" ? true : undefined,
},
optimizeDeps: {
include: ["@libsql/client"],
},
define: {
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
__PREFIX__: JSON.stringify(prefix),
},
}));