Files
2026-06-22 16:40:28 -04:00

260 lines
9.3 KiB
TypeScript

import { createHash } from "node:crypto";
import PageError from "~/components/page-error";
import {
appConfigContext,
authContext,
headscaleConfigContext,
headscaleLiveStoreContext,
requestApiContext,
} from "~/server/context";
import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
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 auth = context.get(authContext);
const config = context.get(appConfigContext);
const getRequestApi = context.get(requestApiContext);
const headscaleConfig = context.get(headscaleConfigContext);
const headscaleLiveStore = context.get(headscaleLiveStoreContext);
const principal = await auth.require(request);
const check = await 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 auth.can(principal, Capabilities.write_users);
// Primary data: Headplane users from the database (always available)
const hpUsers = await auth.listUsers();
// Secondary data: Headscale API (may fail)
let apiUsers: User[] = [];
let nodes: Machine[] = [];
let apiError: string | undefined;
try {
const { api } = await getRequestApi(request);
const [nodesSnap, usersSnap] = await Promise.all([
headscaleLiveStore.get(nodesResource, api),
headscaleLiveStore.get(usersResource, api),
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
} 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 = 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 magic = headscaleConfig.getMagicDNSBaseDomain();
const isOwner = isUserPrincipal(principal) && principal.user.role === "owner";
return {
writable: writablePermission,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
magic,
apiError,
headplaneUsers,
unlinkedHeadscaleUsers,
headscaleUsersForLink,
};
}
export const action = userAction;
export default function Page({ loaderData }: Route.ComponentProps) {
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} />
{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
isSelf={user.id === loaderData.currentUserId}
isOwner={loaderData.isOwner}
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} writable={loaderData.writable} />
))}
</tbody>
</table>
</div>
</section>
)}
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <PageError error={error} page="Users" />;
}