feat: redo user page to match account linking

This commit is contained in:
Aarnav Tale
2026-03-14 14:56:04 -04:00
parent 225a52ded6
commit e255407115
11 changed files with 517 additions and 373 deletions
@@ -0,0 +1,95 @@
import { CircleUser } from "lucide-react";
import StatusCircle from "~/components/StatusCircle";
import type { Role } from "~/server/web/roles";
import cn from "~/utils/cn";
import type { HeadplaneUserData } from "../overview";
import MenuOptions from "./menu";
interface HeadplaneUserRowProps {
user: HeadplaneUserData;
headscaleUsers: { id: string; name: string; claimed: boolean }[];
}
export default function HeadplaneUserRow({ user, headscaleUsers }: HeadplaneUserRowProps) {
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 displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
) : (
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className="leading-snug font-semibold">{displayName}</p>
{displayEmail && <p className="text-sm opacity-50">{displayEmail}</p>}
{!user.headscaleUserId && (
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
)}
</div>
</div>
</td>
<td className="py-2 pl-0.5">
<p>{mapRoleToName(user.role)}</p>
</td>
<td className="py-2 pl-0.5">
<p className="text-sm text-mist-600 dark:text-mist-300" suppressHydrationWarning>
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : "Never"}
</p>
</td>
<td className="py-2 pl-0.5">
{user.machines.length > 0 ? (
<span
className={cn("flex items-center gap-x-1 text-sm", "text-mist-600 dark:text-mist-300")}
>
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
</p>
</span>
) : (
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
<MenuOptions
currentLink={user.headscaleUserId ?? undefined}
headscaleUsers={headscaleUsers}
user={user}
/>
</td>
</tr>
);
}
function mapRoleToName(role: Role) {
switch (role) {
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 "viewer":
return "Viewer";
case "member":
return <p className="opacity-50">Member</p>;
default:
return "Unknown";
}
}
@@ -0,0 +1,62 @@
import { CircleUser } from "lucide-react";
import StatusCircle from "~/components/StatusCircle";
import cn from "~/utils/cn";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
}
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
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-mist-50 dark:hover:bg-mist-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="leading-snug font-semibold">{user.name || user.displayName}</p>
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
</div>
</div>
</td>
<td className="py-2 pl-0.5">
<p className="text-sm text-mist-600 dark:text-mist-300" suppressHydrationWarning>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="py-2 pl-0.5">
{user.machines.length > 0 ? (
<span
className={cn("flex items-center gap-x-1 text-sm", "text-mist-600 dark:text-mist-300")}
>
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
</p>
</span>
) : (
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
</td>
</tr>
);
}
+30 -40
View File
@@ -1,7 +1,7 @@
import { Building2, House, Key } from "lucide-react";
import { Building2, House } from "lucide-react";
import Card from "~/components/Card";
import Link from "~/components/link";
import cn from "~/utils/cn";
import CreateUser from "../dialogs/create-user";
@@ -12,44 +12,34 @@ interface ManageBannerProps {
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<div className="flex flex-col md:flex-row">
<div className="w-full border-b border-mist-100 p-4 md:border-b-0 dark:border-mist-800">
{oidc ? <Building2 className="mb-2 h-5 w-5" /> : <House className="mb-2 h-5 w-5" />}
<h2 className="mb-1 font-medium">{oidc ? "OpenID Connect" : "User Authentication"}</h2>
<p className="text-sm text-mist-600 dark:text-mist-300">
{oidc ? (
<>
Users are managed through your{" "}
<Link external styled to={oidc.issuer}>
OpenID Connect provider
</Link>
{". "}
Groups and user information do not automatically sync.{" "}
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
) : (
<>
Users are not managed externally. Using OpenID Connect can create a better
experience when using Headscale.{" "}
<Link to="https://headscale.net/stable/ref/oidc">Learn more</Link>
</>
)}
</p>
</div>
<div className="w-full border-mist-100 p-4 md:border-l dark:border-mist-800">
<Key className="mb-2 h-5 w-5" />
<h2 className="mb-1 font-medium">User Management</h2>
<p className="text-sm text-mist-600 dark:text-mist-300">
{oidc
? "You can still add users manually, however it is recommended that you manage users through your OIDC provider."
: "You can add, remove, and rename users here."}
</p>
<div className="mt-4 flex items-center gap-2">
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
<div
className={cn(
"mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between",
"rounded-lg border border-mist-200 p-4 dark:border-mist-800",
)}
>
<div className="flex items-center gap-3">
{oidc ? <Building2 className="h-5 w-5 shrink-0" /> : <House className="h-5 w-5 shrink-0" />}
<p className="text-sm text-mist-600 dark:text-mist-300">
{oidc ? (
<>
Users are managed through your{" "}
<Link external styled to={oidc.issuer}>
OIDC provider
</Link>
.
</>
) : (
<>
Users are managed locally.{" "}
<Link styled to="https://headscale.net/stable/ref/oidc">
Set up OIDC
</Link>
</>
)}
</p>
</div>
</Card>
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
);
}
+26 -33
View File
@@ -2,75 +2,67 @@ import { Ellipsis } from "lucide-react";
import { useState } from "react";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { Machine, User } from "~/types";
import Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import Rename from "../dialogs/rename-user";
import type { HeadplaneUserData } from "../overview";
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
user: HeadplaneUserData;
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
type Modal = "rename" | "delete" | "reassign" | "link" | null;
type Modal = "delete" | "reassign" | "link" | null;
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const isLinked = currentLink !== undefined;
const disabledKeys: string[] = [];
if (user.provider === "oidc") {
disabledKeys.push("rename");
} else {
disabledKeys.push("reassign", "link");
if (!isLinked) {
disabledKeys.push("reassign");
}
// Filter linkable users: unclaimed, or the one currently linked to this user
const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink);
const displayName = user.linkedHeadscaleUser?.displayName || user.name || user.email || user.sub;
return (
<>
{modal === "rename" && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
{modal === "delete" && user.linkedHeadscaleUser && (
<Delete
isOpen={modal === "delete"}
machines={user.machines}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
user={user.linkedHeadscaleUser}
/>
)}
{modal === "reassign" && (
<Reassign
displayName={displayName}
isOpen={modal === "reassign"}
role={user.role}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
displayName={displayName}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
userId={user.linkedHeadscaleUser?.id ?? user.id}
/>
)}
@@ -79,22 +71,23 @@ export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProp
<Ellipsis className="h-5" />
</MenuTrigger>
<MenuContent>
<MenuItem disabled={disabledKeys.includes("rename")} onClick={() => setModal("rename")}>
Rename user
</MenuItem>
<MenuItem
disabled={disabledKeys.includes("reassign")}
onClick={() => setModal("reassign")}
>
Change role
</MenuItem>
<MenuItem disabled={disabledKeys.includes("link")} onClick={() => setModal("link")}>
Link Headscale user
</MenuItem>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
<MenuItem onClick={() => setModal("link")}>
{isLinked ? "Change linked user" : "Link Headscale user"}
</MenuItem>
{user.linkedHeadscaleUser && (
<>
<MenuSeparator />
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
</>
)}
</MenuContent>
</Menu>
</>
-94
View File
@@ -1,94 +0,0 @@
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[] };
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
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()),
0,
);
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-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-sm text-mist-600 dark:text-mist-300" 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-mist-600 dark:text-mist-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
currentLink={currentLink}
headscaleUsers={headscaleUsers}
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 <p className="opacity-50">No Access</p>;
default:
return "Unknown";
}
}
+52 -58
View File
@@ -1,66 +1,60 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
interface CreateUserProps {
isOidc?: boolean;
isDisabled?: boolean;
isOidc?: boolean;
isDisabled?: boolean;
}
// TODO: Support image upload for user avatars
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a username to create a new user. Usernames can be addressed when
managing ACL policies.
{isOidc ? (
<>
{' '}
Manually created users are given administrative access to
Headplane unless they become linked to an OIDC user in Headscale.
</>
) : undefined}
</Dialog.Text>
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
label="Username"
name="username"
placeholder="my-new-user"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return 'Username is required';
}
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Create a Headscale user</Dialog.Title>
<Dialog.Text className="mb-6">
This creates a new user in Headscale. The user will appear in the &ldquo;Unlinked
Headscale Users&rdquo; section until they sign in
{isOidc ? " through your OIDC provider" : ""} and are automatically linked to a Headplane
account.
</Dialog.Text>
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
label="Username"
name="username"
placeholder="my-new-user"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return "Username is required";
}
if (value.includes(' ')) {
return 'Usernames cannot contain spaces';
}
if (value.includes(" ")) {
return "Usernames cannot contain spaces";
}
return true;
}}
validationBehavior="native"
/>
<Input
label="Display Name"
name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email"
name="email"
placeholder="name@example.com"
type="email"
validationBehavior="native"
/>
</div>
</Dialog.Panel>
</Dialog>
);
return true;
}}
validationBehavior="native"
/>
<Input
label="Display Name"
name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email"
name="email"
placeholder="name@example.com"
type="email"
validationBehavior="native"
/>
</div>
</Dialog.Panel>
</Dialog>
);
}
+6 -5
View File
@@ -1,20 +1,21 @@
import Dialog from "~/components/Dialog";
import { Machine, User } from "~/types";
import type { Machine, User } from "~/types";
interface DeleteProps {
user: User & { machines: Machine[] };
user: User;
machines: Machine[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
export default function DeleteUser({ user, machines, isOpen, setIsOpen }: DeleteProps) {
const name = user.name || user.displayName;
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Panel variant={machines.length > 0 ? "unactionable" : "normal"}>
<Dialog.Title>Delete {name}?</Dialog.Title>
{user.machines.length > 0 ? (
{machines.length > 0 ? (
<Dialog.Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or re-assign their machines
to other users before proceeding.
+7 -6
View File
@@ -1,10 +1,10 @@
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 };
userId: string;
displayName: string;
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
isOpen: boolean;
@@ -12,7 +12,8 @@ interface LinkUserProps {
}
export default function LinkUser({
user,
userId,
displayName,
headscaleUsers,
currentLink,
isOpen,
@@ -21,9 +22,9 @@ export default function LinkUser({
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
<Dialog.Title>Link Headscale user for {displayName}</Dialog.Title>
<Dialog.Text className="mb-6">
Select which Headscale user this OIDC identity should be linked to. This controls which
Select which Headscale user this identity should be linked to. This controls which
machines they can manage and enables self-service features.
</Dialog.Text>
{headscaleUsers.length === 0 ? (
@@ -31,7 +32,7 @@ export default function LinkUser({
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={userId} />
<select
className={cn(
"w-full rounded-lg border p-2",
+29 -17
View File
@@ -3,46 +3,53 @@ import Link from "~/components/link";
import Notice from "~/components/Notice";
import RadioGroup from "~/components/RadioGroup";
import { Roles } from "~/server/web/roles";
import { User } from "~/types";
import type { Role } from "~/server/web/roles";
interface ReassignProps {
user: User & { headplaneRole: string };
userId: string;
displayName: string;
role: Role;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
export default function ReassignUser({
userId,
displayName,
role,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant={user.headplaneRole === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Panel variant={role === "owner" ? "unactionable" : "normal"}>
<Dialog.Title>Change role for {displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind that I have not
fully implemented permissions yet and some things may be accessible to everyone. The only
fully completed role is Member.{" "}
Roles control what the user can access in Headplane. Each role grants a specific set of
capabilities.{" "}
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === "owner" ? (
{role === "owner" ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<input name="user_id" type="hidden" value={userId} />
<RadioGroup
className="gap-4"
defaultValue={user.headplaneRole}
defaultValue={role}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== "owner")
.map((role) => {
const { name, desc } = mapRoleToName(role);
.filter((r) => r !== "owner")
.map((r) => {
const { name, desc } = mapRoleToName(r);
return (
<RadioGroup.Radio key={role} label={name} value={role}>
<RadioGroup.Radio key={r} label={name} value={r}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
@@ -80,6 +87,11 @@ function mapRoleToName(role: string) {
name: "Auditor",
desc: "Can view the admin console.",
};
case "viewer":
return {
name: "Viewer",
desc: "Can view machines, users, and generate their own auth keys.",
};
case "member":
return {
name: "Member",
@@ -87,8 +99,8 @@ function mapRoleToName(role: string) {
};
default:
return {
name: "Unknown",
desc: "Unknown",
name: role,
desc: "No description available.",
};
}
}
+183 -119
View File
@@ -1,22 +1,35 @@
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { useEffect, useState } from "react";
import PageError from "~/components/page-error";
import { users as usersTable } from "~/server/db/schema";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import cn from "~/utils/cn";
import log from "~/utils/log";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
import HeadplaneUserRow from "./components/headplane-user-row";
import HeadscaleUserRow from "./components/headscale-user-row";
import ManageBanner from "./components/manage-banner";
import UserRow from "./components/user-row";
import { userAction } from "./user-actions";
interface UserMachine extends User {
export interface HeadplaneUserData {
id: string;
sub: string;
name: string | null;
email: string | null;
role: Role;
headscaleUserId: string | null;
createdAt: Date | null;
lastLoginAt: Date | null;
// Enriched from Headscale API (may be absent if API failed)
linkedHeadscaleUser?: User;
machines: Machine[];
profilePicUrl?: string;
}
export interface UnlinkedHeadscaleUser extends User {
machines: Machine[];
}
@@ -24,7 +37,6 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, 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.",
);
@@ -32,45 +44,81 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
// Primary data: Headplane users from the database (always available)
const hpUsers = await context.auth.listUsers();
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;
}
// Secondary data: Headscale API (may fail)
let apiUsers: User[] = [];
let nodes: Machine[] = [];
let apiError: string | 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,
try {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
[nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
} catch (error) {
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
apiError =
"Could not connect to the Headscale API. Headscale user data and machine information are unavailable.";
}
const useGravatar = context.config.oidc?.profile_picture_source === "gravatar";
function resolveProfilePic(email?: string, profilePicUrl?: string): string | undefined {
if (!useGravatar) return profilePicUrl;
if (!email) return undefined;
const hash = createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
// Build a lookup from Headscale user ID → Headscale user
const hsUserMap = new Map<string, User>();
for (const u of apiUsers) {
hsUserMap.set(u.id, u);
}
// Build the primary user list: Headplane users enriched with Headscale data
const headplaneUsers: HeadplaneUserData[] = hpUsers
.sort((a, b) => (a.name ?? a.sub).localeCompare(b.name ?? b.sub))
.map((hp) => {
const hsUser = hp.headscale_user_id ? hsUserMap.get(hp.headscale_user_id) : undefined;
const machines = hsUser ? nodes.filter((n) => n.user?.id === hsUser.id) : [];
return {
id: hp.id,
sub: hp.sub,
name: hp.name,
email: hp.email,
role: (hp.role in Roles ? hp.role : "member") as Role,
headscaleUserId: hp.headscale_user_id,
createdAt: hp.created_at,
lastLoginAt: hp.last_login_at,
linkedHeadscaleUser: hsUser,
machines,
profilePicUrl: hsUser
? resolveProfilePic(hsUser.email, hsUser.profilePicUrl)
: resolveProfilePic(hp.email ?? undefined),
};
});
// Build the unlinked Headscale users list
const claimedIds = new Set(hpUsers.map((u) => u.headscale_user_id).filter(Boolean));
const unlinkedHeadscaleUsers: UnlinkedHeadscaleUser[] = apiUsers
.filter((u) => !claimedIds.has(u.id))
.sort((a, b) => a.name.localeCompare(b.name))
.map((u) => ({
...u,
machines: nodes.filter((n) => n.user?.id === u.id),
profilePicUrl: resolveProfilePic(u.email, u.profilePicUrl),
}));
// Build linkable Headscale users for admin link dialog
const headscaleUsersForLink = apiUsers.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
claimed: claimedIds.has(u.id),
}));
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 subject = getOidcSubject(user);
if (!subject) {
return "invalid-oidc";
}
const role = await context.auth.roleForSubject(subject);
return role ?? "no-role";
}),
);
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
@@ -78,94 +126,110 @@ 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
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
writable: writablePermission,
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
magic,
users,
headscaleUsers,
userLinks,
apiError,
headplaneUsers,
unlinkedHeadscaleUsers,
headscaleUsersForLink,
};
}
export const action = userAction;
export default function Page({ loaderData }: Route.ComponentProps) {
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]);
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-mist-600 dark:text-mist-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>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
currentLink={loaderData.userLinks[user.id]}
headscaleUsers={loaderData.headscaleUsers}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
{loaderData.apiError && (
<div
className={cn(
"mb-6 flex items-start gap-3 rounded-lg border p-4",
"border-red-200 bg-red-50 text-red-800",
"dark:border-red-800 dark:bg-red-950 dark:text-red-200",
)}
>
<p className="text-sm">{loaderData.apiError}</p>
</div>
)}
<section>
<h2 className="mb-3 text-lg font-medium">Headplane Users</h2>
{loaderData.headplaneUsers.length === 0 ? (
<p className="text-sm text-mist-600 dark:text-mist-300">
No users have signed into Headplane yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-mist-600 dark:text-mist-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">Last Login</th>
<th className="pb-2 text-xs font-bold uppercase">Status</th>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{loaderData.headplaneUsers.map((user) => (
<HeadplaneUserRow
key={user.id}
headscaleUsers={loaderData.headscaleUsersForLink}
user={user}
/>
))}
</tbody>
</table>
</div>
)}
</section>
{!loaderData.apiError && loaderData.unlinkedHeadscaleUsers.length > 0 && (
<section className="mt-10">
<h2 className="mb-1 text-lg font-medium">Unlinked Headscale Users</h2>
<p className="mb-3 text-sm text-mist-600 dark:text-mist-300">
These Headscale users are not linked to a Headplane account and cannot be managed
through Headplane.
</p>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-mist-600 dark:text-mist-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">Created At</th>
<th className="pb-2 text-xs font-bold uppercase">Status</th>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} />
))}
</tbody>
</table>
</div>
</section>
)}
</>
);
}
+27 -1
View File
@@ -7,7 +7,7 @@ import { ulid } from "ulidx";
import type { Machine } from "~/types";
import { authSessions, users } from "../db/schema";
import { type HeadplaneUser, authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
// ── Principal ────────────────────────────────────────────────────────
@@ -378,6 +378,14 @@ export class AuthService {
return this.linkHeadscaleUser(user.id, headscaleUserId);
}
/**
* List all Headplane user records. Used by the users overview page
* to display the primary user list independently of the Headscale API.
*/
async listUsers(): Promise<HeadplaneUser[]> {
return this.opts.db.select().from(users);
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the link picker.
@@ -408,6 +416,24 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Get the role for a Headplane user linked to a given Headscale user ID.
* Returns undefined if no Headplane user is linked to this Headscale user.
*/
async roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await this.opts.db
.select()
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
.limit(1);
if (!user) {
return;
}
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.