mirror of
https://github.com/tale/headplane.git
synced 2026-08-21 02:06:37 +00:00
feat: redo user page to match account linking
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user