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";
}
}