import { createHash } from "node:crypto"; import { useEffect, useState } from "react"; import type { Machine, User } from "~/types"; import { Capabilities } from "~/server/web/roles"; import cn from "~/utils/cn"; import type { Route } from "./+types/overview"; import ManageBanner from "./components/manage-banner"; import UserRow from "./components/user-row"; import { userAction } from "./user-actions"; interface UserMachine extends User { machines: Machine[]; } export async function loader({ request, context }: Route.LoaderArgs) { const session = await context.sessions.auth(request); const check = await context.sessions.check(request, 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.", ); } const writablePermission = await context.sessions.check(request, Capabilities.write_users); const api = context.hsApi.getRuntimeClient(session.api_key); const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]); 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; } 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, })); const roles = await Promise.all( users .sort((a, b) => a.name.localeCompare(b.name)) .map(async (user) => { if (user.provider !== "oidc") { return "no-oidc"; } if (user.provider === "oidc" && user.providerId) { // For some reason, headscale makes providerID a url where the // last component is the subject, so we need to strip that out const subject = user.providerId.split("/").pop(); if (!subject) { return "invalid-oidc"; } const role = await context.sessions.roleForSubject(subject); return role ?? "no-role"; } // No role means the user is not registered in Headplane, but they // are in Headscale. We also need to handle what happens if someone // logs into the UI and they don't have a Headscale setup. return "no-role"; }), ); let magic: string | undefined; if (context.hs.readable()) { if (context.hs.c?.dns.magic_dns) { magic = context.hs.c.dns.base_domain; } } return { writable: writablePermission, // whether the user can write to the API oidc: context.config.oidc ? { issuer: context.config.oidc.issuer, } : undefined, roles, magic, users, }; } export const action = userAction; export default function Page({ loaderData }: Route.ComponentProps) { const [users, setUsers] = useState(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 ( <>

Users

Manage the users in your network and their permissions.

{users .sort((a, b) => a.name.localeCompare(b.name)) .map((user) => ( ))}
User Role Created At Last Seen
); }