feat: admin UI for Headscale user linking + skip onboarding

- Add 'Link Headscale user' option in user menu (admin-only, OIDC users)
- Dialog shows only unclaimed Headscale users
- New link_user action in user-actions with claim validation
- Onboarding now allows skipping the link step with clear messaging
- Users who skip are told they can ask an admin to link later

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
This commit is contained in:
Aarnav Tale
2026-03-08 00:51:23 -05:00
parent 684a95b5e8
commit 434f886034
7 changed files with 277 additions and 93 deletions
+95 -67
View File
@@ -1,74 +1,102 @@
import { Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete-user';
import Reassign from '../dialogs/reassign-user';
import Rename from '../dialogs/rename-user';
import { Ellipsis } from "lucide-react";
import { useState } from "react";
import Menu from "~/components/Menu";
import type { Machine, User } from "~/types";
import cn from "~/utils/cn";
import Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import Rename from "../dialogs/rename-user";
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
user: User & {
headplaneRole: string;
machines: Machine[];
};
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
type Modal = 'rename' | 'delete' | 'reassign' | null;
type Modal = "rename" | "delete" | "reassign" | "link" | null;
export default function UserMenu({ user }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
return (
<>
{modal === 'rename' && (
<Rename
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'delete' && (
<Delete
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'reassign' && (
<Reassign
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
const disabledKeys: string[] = [];
if (user.provider === "oidc") {
disabledKeys.push("rename");
} else {
disabledKeys.push("reassign", "link");
}
// Filter linkable users: unclaimed, or the one currently linked to this user
const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink);
return (
<>
{modal === "rename" && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "reassign" && (
<Reassign
isOpen={modal === "reassign"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu disabledKeys={disabledKeys}>
<Menu.IconButton
className={cn(
"w-10 border-transparent bg-transparent py-0.5",
"border group-hover:border-headplane-200",
"dark:group-hover:border-headplane-700",
)}
label="User Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="link">Link Headscale user</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}
+8 -2
View File
@@ -9,9 +9,11 @@ import MenuOptions from "./menu";
interface UserRowProps {
role: string;
user: User & { machines: Machine[] };
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
export default function UserRow({ user, role }: UserRowProps) {
export default function UserRow({ user, role, headscaleUsers, currentLink }: UserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -59,7 +61,11 @@ export default function UserRow({ user, role }: UserRowProps) {
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={{ ...user, headplaneRole: role }} />
<MenuOptions
currentLink={currentLink}
headscaleUsers={headscaleUsers}
user={{ ...user, headplaneRole: role }}
/>
</td>
</tr>
);
+58
View File
@@ -0,0 +1,58 @@
import Dialog from "~/components/Dialog";
import Notice from "~/components/Notice";
import type { User } from "~/types";
import cn from "~/utils/cn";
interface LinkUserProps {
user: User & { headplaneRole: string };
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function LinkUser({
user,
headscaleUsers,
currentLink,
isOpen,
setIsOpen,
}: LinkUserProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
<Dialog.Text className="mb-6">
Select which Headscale user this OIDC identity should be linked to. This controls which
machines they can manage and enables self-service features.
</Dialog.Text>
{headscaleUsers.length === 0 ? (
<Notice>All Headscale users are already linked to other accounts.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={user.id} />
<select
className={cn(
"w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
defaultValue={currentLink ?? ""}
name="headscale_user_id"
required
>
<option value="">Select a Headscale user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
{u.id === currentLink ? " (current)" : ""}
</option>
))}
</select>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+36 -23
View File
@@ -125,34 +125,47 @@ export default function Page({
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">
{needsUserLink && headscaleUsers.length > 0 ? (
{needsUserLink ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
<Card.Text className="mb-4">
Headplane couldn't automatically match your SSO identity to a Headscale user. Select
which Headscale user you are to continue.
Headplane couldn't automatically match your SSO identity to a Headscale user.
{headscaleUsers.length > 0
? " Select which Headscale user you are, or skip to continue without linking."
: " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."}
</Card.Text>
<Form method="POST" action="/onboarding/skip">
<select
className={cn(
"w-full rounded-lg border p-2 mb-4",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
{headscaleUsers.length > 0 ? (
<Form method="POST" action="/onboarding/skip">
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
) : undefined}
<NavLink className="mt-3 block text-center" to="/onboarding/skip">
<Button className="w-full" variant="light">
Skip — I'll do this later
</Button>
</Form>
</NavLink>
<p className="text-headplane-500 mt-2 text-center text-xs">
Without linking, you won't be able to see your own machines or generate pre-auth keys.
An admin can link your account later from the Users page.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
+34 -1
View File
@@ -1,11 +1,14 @@
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { useEffect, useState } from "react";
import { users as usersTable } from "~/server/db/schema";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
import ManageBanner from "./components/manage-banner";
@@ -74,6 +77,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
}
// Build linkable Headscale users for admin link dialog
const claimed = await context.auth.claimedHeadscaleUserIds();
const headscaleUsers = apiUsers.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
claimed: claimed.has(u.id),
}));
// Build a map of Headscale user -> linked Headplane subject
const userLinks: Record<string, string | undefined> = {};
for (const u of apiUsers) {
const subject = getOidcSubject(u);
if (subject) {
const [hp] = await context.db
.select({ hsId: usersTable.headscale_user_id })
.from(usersTable)
.where(eq(usersTable.sub, subject))
.limit(1);
userLinks[u.id] = hp?.hsId ?? undefined;
}
}
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc
@@ -84,6 +109,8 @@ export async function loader({ request, context }: Route.LoaderArgs) {
roles,
magic,
users,
headscaleUsers,
userLinks,
};
}
@@ -124,7 +151,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow key={user.id} role={loaderData.roles[users.indexOf(user)]} user={user} />
<UserRow
key={user.id}
currentLink={loaderData.userLinks[user.id]}
headscaleUsers={loaderData.headscaleUsers}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
+27
View File
@@ -104,6 +104,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "User reassigned successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
return { message: "Headscale user linked successfully" };
}
default:
throw data("Invalid `action_id` provided.", {
status: 400,
+19
View File
@@ -339,6 +339,25 @@ export class AuthService {
return true;
}
/**
* Link a Headplane user (identified by OIDC subject) to a Headscale
* user. Used by admin UI when subjects are more accessible than
* internal Headplane IDs. Returns false if already claimed.
*/
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
const [user] = await this.opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return false;
}
return this.linkHeadscaleUser(user.id, headscaleUserId);
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the onboarding dropdown.