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>
);