mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +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";
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,60 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
|
||||
interface CreateUserProps {
|
||||
isOidc?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isOidc?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Support image upload for user avatars
|
||||
export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add a new user</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a username to create a new user. Usernames can be addressed when
|
||||
managing ACL policies.
|
||||
{isOidc ? (
|
||||
<>
|
||||
{' '}
|
||||
Manually created users are given administrative access to
|
||||
Headplane unless they become linked to an OIDC user in Headscale.
|
||||
</>
|
||||
) : undefined}
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="Username"
|
||||
name="username"
|
||||
placeholder="my-new-user"
|
||||
type="text"
|
||||
validate={(value) => {
|
||||
if (value.trim().length === 0) {
|
||||
return 'Username is required';
|
||||
}
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Create a Headscale user</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
This creates a new user in Headscale. The user will appear in the “Unlinked
|
||||
Headscale Users” section until they sign in
|
||||
{isOidc ? " through your OIDC provider" : ""} and are automatically linked to a Headplane
|
||||
account.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="Username"
|
||||
name="username"
|
||||
placeholder="my-new-user"
|
||||
type="text"
|
||||
validate={(value) => {
|
||||
if (value.trim().length === 0) {
|
||||
return "Username is required";
|
||||
}
|
||||
|
||||
if (value.includes(' ')) {
|
||||
return 'Usernames cannot contain spaces';
|
||||
}
|
||||
if (value.includes(" ")) {
|
||||
return "Usernames cannot contain spaces";
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Display Name"
|
||||
name="display_name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
return true;
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Display Name"
|
||||
name="display_name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import Dialog from "~/components/Dialog";
|
||||
import { Machine, User } from "~/types";
|
||||
import type { Machine, User } from "~/types";
|
||||
|
||||
interface DeleteProps {
|
||||
user: User & { machines: Machine[] };
|
||||
user: User;
|
||||
machines: Machine[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||
export default function DeleteUser({ user, machines, isOpen, setIsOpen }: DeleteProps) {
|
||||
const name = user.name || user.displayName;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant={user.machines.length > 0 ? "unactionable" : "normal"}>
|
||||
<Dialog.Panel variant={machines.length > 0 ? "unactionable" : "normal"}>
|
||||
<Dialog.Title>Delete {name}?</Dialog.Title>
|
||||
{user.machines.length > 0 ? (
|
||||
{machines.length > 0 ? (
|
||||
<Dialog.Text className="mb-6">
|
||||
Users cannot be deleted if they have machines. Please delete or re-assign their machines
|
||||
to other users before proceeding.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Notice from "~/components/Notice";
|
||||
import type { User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
user: User & { headplaneRole: string };
|
||||
userId: string;
|
||||
displayName: string;
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
currentLink?: string;
|
||||
isOpen: boolean;
|
||||
@@ -12,7 +12,8 @@ interface LinkUserProps {
|
||||
}
|
||||
|
||||
export default function LinkUser({
|
||||
user,
|
||||
userId,
|
||||
displayName,
|
||||
headscaleUsers,
|
||||
currentLink,
|
||||
isOpen,
|
||||
@@ -21,9 +22,9 @@ export default function LinkUser({
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
|
||||
<Dialog.Title>Link Headscale user for {displayName}</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Select which Headscale user this OIDC identity should be linked to. This controls which
|
||||
Select which Headscale user this identity should be linked to. This controls which
|
||||
machines they can manage and enables self-service features.
|
||||
</Dialog.Text>
|
||||
{headscaleUsers.length === 0 ? (
|
||||
@@ -31,7 +32,7 @@ export default function LinkUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
|
||||
@@ -3,46 +3,53 @@ import Link from "~/components/link";
|
||||
import Notice from "~/components/Notice";
|
||||
import RadioGroup from "~/components/RadioGroup";
|
||||
import { Roles } from "~/server/web/roles";
|
||||
import { User } from "~/types";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
interface ReassignProps {
|
||||
user: User & { headplaneRole: string };
|
||||
userId: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ReassignUser({ user, isOpen, setIsOpen }: ReassignProps) {
|
||||
export default function ReassignUser({
|
||||
userId,
|
||||
displayName,
|
||||
role,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: ReassignProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant={user.headplaneRole === "owner" ? "unactionable" : "normal"}>
|
||||
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
|
||||
<Dialog.Panel variant={role === "owner" ? "unactionable" : "normal"}>
|
||||
<Dialog.Title>Change role for {displayName}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Most roles are carried straight from Tailscale. However, keep in mind that I have not
|
||||
fully implemented permissions yet and some things may be accessible to everyone. The only
|
||||
fully completed role is Member.{" "}
|
||||
Roles control what the user can access in Headplane. Each role grants a specific set of
|
||||
capabilities.{" "}
|
||||
<Link external styled to="https://tailscale.com/kb/1138/user-roles">
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
{user.headplaneRole === "owner" ? (
|
||||
{role === "owner" ? (
|
||||
<Notice>The Tailnet owner cannot be reassigned.</Notice>
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<RadioGroup
|
||||
className="gap-4"
|
||||
defaultValue={user.headplaneRole}
|
||||
defaultValue={role}
|
||||
isRequired
|
||||
label="Role"
|
||||
name="new_role"
|
||||
>
|
||||
{Object.keys(Roles)
|
||||
.filter((role) => role !== "owner")
|
||||
.map((role) => {
|
||||
const { name, desc } = mapRoleToName(role);
|
||||
.filter((r) => r !== "owner")
|
||||
.map((r) => {
|
||||
const { name, desc } = mapRoleToName(r);
|
||||
return (
|
||||
<RadioGroup.Radio key={role} label={name} value={role}>
|
||||
<RadioGroup.Radio key={r} label={name} value={r}>
|
||||
<div className="block">
|
||||
<p className="font-bold">{name}</p>
|
||||
<p className="opacity-70">{desc}</p>
|
||||
@@ -80,6 +87,11 @@ function mapRoleToName(role: string) {
|
||||
name: "Auditor",
|
||||
desc: "Can view the admin console.",
|
||||
};
|
||||
case "viewer":
|
||||
return {
|
||||
name: "Viewer",
|
||||
desc: "Can view machines, users, and generate their own auth keys.",
|
||||
};
|
||||
case "member":
|
||||
return {
|
||||
name: "Member",
|
||||
@@ -87,8 +99,8 @@ function mapRoleToName(role: string) {
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: "Unknown",
|
||||
desc: "Unknown",
|
||||
name: role,
|
||||
desc: "No description available.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+183
-119
@@ -1,22 +1,35 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import PageError from "~/components/page-error";
|
||||
import { users as usersTable } from "~/server/db/schema";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
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 UserRow from "./components/user-row";
|
||||
import { userAction } from "./user-actions";
|
||||
|
||||
interface UserMachine extends User {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -24,7 +37,6 @@ 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) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
@@ -32,45 +44,81 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
// Primary data: Headplane users from the database (always available)
|
||||
const hpUsers = await context.auth.listUsers();
|
||||
|
||||
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;
|
||||
}
|
||||
// Secondary data: Headscale API (may fail)
|
||||
let apiUsers: User[] = [];
|
||||
let nodes: Machine[] = [];
|
||||
let apiError: string | 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,
|
||||
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<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 roles = await Promise.all(
|
||||
users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(async (user) => {
|
||||
if (user.provider !== "oidc") {
|
||||
return "no-oidc";
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
return "invalid-oidc";
|
||||
}
|
||||
|
||||
const role = await context.auth.roleForSubject(subject);
|
||||
return role ?? "no-role";
|
||||
}),
|
||||
);
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
@@ -78,94 +126,110 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build linkable Headscale users for admin link dialog
|
||||
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||
const headscaleUsers = apiUsers.map((u) => ({
|
||||
id: u.id,
|
||||
name: getUserDisplayName(u),
|
||||
claimed: claimed.has(u.id),
|
||||
}));
|
||||
|
||||
// Build a map of Headscale user -> linked Headplane subject
|
||||
const userLinks: Record<string, string | undefined> = {};
|
||||
for (const u of apiUsers) {
|
||||
const subject = getOidcSubject(u);
|
||||
if (subject) {
|
||||
const [hp] = await context.db
|
||||
.select({ hsId: usersTable.headscale_user_id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.sub, subject))
|
||||
.limit(1);
|
||||
userLinks[u.id] = hp?.hsId ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
writable: writablePermission, // whether the user can write to the API
|
||||
oidc: context.config.oidc
|
||||
? {
|
||||
issuer: context.config.oidc.issuer,
|
||||
}
|
||||
: undefined,
|
||||
roles,
|
||||
writable: writablePermission,
|
||||
oidc: context.config.oidc ? { issuer: context.config.oidc.issuer } : undefined,
|
||||
magic,
|
||||
users,
|
||||
headscaleUsers,
|
||||
userLinks,
|
||||
apiError,
|
||||
headplaneUsers,
|
||||
unlinkedHeadscaleUsers,
|
||||
headscaleUsersForLink,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = userAction;
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const [users, setUsers] = useState<UserMachine[]>(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 (
|
||||
<>
|
||||
<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} />
|
||||
<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">Created At</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Last Seen</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",
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
currentLink={loaderData.userLinks[user.id]}
|
||||
headscaleUsers={loaderData.headscaleUsers}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{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
|
||||
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} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+27
-1
@@ -7,7 +7,7 @@ import { ulid } from "ulidx";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import { authSessions, users } from "../db/schema";
|
||||
import { type HeadplaneUser, authSessions, users } from "../db/schema";
|
||||
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
|
||||
|
||||
// ── Principal ────────────────────────────────────────────────────────
|
||||
@@ -378,6 +378,14 @@ export class AuthService {
|
||||
return this.linkHeadscaleUser(user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Headplane user records. Used by the users overview page
|
||||
* to display the primary user list independently of the Headscale API.
|
||||
*/
|
||||
async listUsers(): Promise<HeadplaneUser[]> {
|
||||
return this.opts.db.select().from(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of Headscale user IDs that are already claimed
|
||||
* by a Headplane user. Used to filter the link picker.
|
||||
@@ -408,6 +416,24 @@ export class AuthService {
|
||||
return (user.role in Roles ? user.role : "member") as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role for a Headplane user linked to a given Headscale user ID.
|
||||
* Returns undefined if no Headplane user is linked to this Headscale user.
|
||||
*/
|
||||
async roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
|
||||
const [user] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.headscale_user_id, headscaleUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (user.role in Roles ? user.role : "member") as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign the role of a user identified by their OIDC subject.
|
||||
* Cannot reassign the owner role.
|
||||
|
||||
Reference in New Issue
Block a user