import { createHash } from "node:crypto"; import PageError from "~/components/page-error"; 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 { userAction } from "./user-actions"; 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[]; } 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) { throw new Error( "You do not have permission to view this page. Please contact your administrator.", ); } const writablePermission = await context.auth.can(principal, Capabilities.write_users); // Primary data: Headplane users from the database (always available) const hpUsers = await context.auth.listUsers(); // Secondary data: Headscale API (may fail) let apiUsers: User[] = []; let nodes: Machine[] = []; let apiError: string | undefined; 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(); 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), })); let magic: string | undefined; if (context.hs.readable()) { if (context.hs.c?.dns.magic_dns) { magic = context.hs.c.dns.base_domain; } } const isOwner = principal.kind === "oidc" && principal.user.role === "owner"; return { writable: writablePermission, currentUserId: principal.kind === "oidc" ? principal.user.id : undefined, isOwner, oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined, magic, apiError, headplaneUsers, unlinkedHeadscaleUsers, headscaleUsersForLink, }; } export const action = userAction; export default function Page({ loaderData }: Route.ComponentProps) { return ( <>

Users

Manage the users in your network and their permissions.

{loaderData.apiError && (

{loaderData.apiError}

)}

Headplane Users

{loaderData.headplaneUsers.length === 0 ? (

No users have signed into Headplane yet.

) : (
{loaderData.headplaneUsers.map((user) => ( ))}
User Role Last Login Status Actions
)}
{!loaderData.apiError && loaderData.unlinkedHeadscaleUsers.length > 0 && (

Unlinked Headscale Users

These Headscale users are not linked to a Headplane account and cannot be managed through Headplane.

{loaderData.unlinkedHeadscaleUsers.map((user) => ( ))}
User Created At Status Actions
)} ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { return ; }