feat(ui): structured editor for ACL rules, tags and groups (#608)

This commit is contained in:
albedev
2026-08-28 21:58:06 +02:00
committed by GitHub
parent 30c842ed8d
commit 72ea6aa0b3
28 changed files with 2414 additions and 37 deletions
@@ -1,5 +1,6 @@
import { CircleUser } from "lucide-react";
import Chip from "~/components/chip";
import StatusCircle from "~/components/status-circle";
import type { Role } from "~/server/web/roles";
import cn from "~/utils/cn";
@@ -12,6 +13,9 @@ interface HeadplaneUserRowProps {
headscaleUsers: { id: string; name: string; claimed: boolean }[];
isSelf?: boolean;
isOwner?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
export default function HeadplaneUserRow({
@@ -19,6 +23,9 @@ export default function HeadplaneUserRow({
headscaleUsers,
isSelf,
isOwner,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadplaneUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
@@ -50,6 +57,13 @@ export default function HeadplaneUserRow({
{!user.headscaleUserId && (
<p className="text-xs text-amber-600 dark:text-amber-400">Not linked</p>
)}
{user.groups.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{user.groups.map((group) => (
<Chip className="font-mono" key={group} text={group} />
))}
</div>
)}
</div>
</div>
</td>
@@ -77,10 +91,13 @@ export default function HeadplaneUserRow({
</td>
<td className="py-2 pr-0.5">
<MenuOptions
canEditGroups={canEditGroups}
currentLink={user.headscaleUserId ?? undefined}
headscaleUsers={headscaleUsers}
isOwner={isOwner}
isSelf={isSelf}
policyGroups={policyGroups}
policyHasComments={policyHasComments}
user={user}
/>
</td>
@@ -5,15 +5,24 @@ import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/compo
import Delete from "../dialogs/delete-user";
import Rename from "../dialogs/rename-user";
import UserGroups from "../dialogs/user-groups";
import type { UnlinkedHeadscaleUser } from "../overview";
interface HeadscaleUserMenuProps {
user: UnlinkedHeadscaleUser;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
type Modal = "rename" | "delete" | null;
type Modal = "rename" | "groups" | "delete" | null;
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
export default function HeadscaleUserMenu({
user,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadscaleUserMenuProps) {
const [modal, setModal] = useState<Modal>(null);
// Headscale-managed OIDC users cannot be renamed via the API.
@@ -30,6 +39,19 @@ export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
user={user}
/>
)}
{modal === "groups" && canEditGroups && (
<UserGroups
availableGroups={policyGroups ?? []}
policyHasComments={policyHasComments}
displayName={user.displayName || user.name}
groups={user.groups}
isOpen={modal === "groups"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userName={user.name}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
@@ -47,7 +69,8 @@ export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
</MenuTrigger>
<MenuContent>
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
{canRename && <MenuSeparator />}
{canEditGroups && <MenuItem onClick={() => setModal("groups")}>Edit groups</MenuItem>}
{(canRename || canEditGroups) && <MenuSeparator />}
<MenuItem variant="danger" onClick={() => setModal("delete")}>
Delete
</MenuItem>
@@ -1,5 +1,6 @@
import { CircleUser } from "lucide-react";
import Chip from "~/components/chip";
import StatusCircle from "~/components/status-circle";
import cn from "~/utils/cn";
@@ -9,9 +10,18 @@ import HeadscaleUserMenu from "./headscale-user-menu";
interface HeadscaleUserRowProps {
user: UnlinkedHeadscaleUser;
writable?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
export default function HeadscaleUserRow({
user,
writable,
canEditGroups,
policyGroups,
policyHasComments,
}: HeadscaleUserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
@@ -34,6 +44,13 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
<p className="leading-snug font-semibold">{displayName}</p>
{displayUsername && <p className="text-sm opacity-50">{displayUsername}</p>}
{user.email && <p className="text-sm opacity-50">{user.email}</p>}
{user.groups.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{user.groups.map((group) => (
<Chip className="font-mono" key={group} text={group} />
))}
</div>
)}
</div>
</div>
</td>
@@ -56,7 +73,16 @@ export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowPro
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
)}
</td>
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
<td className="py-2 pr-0.5">
{writable ? (
<HeadscaleUserMenu
canEditGroups={canEditGroups}
policyGroups={policyGroups}
policyHasComments={policyHasComments}
user={user}
/>
) : null}
</td>
</tr>
);
}
+24 -1
View File
@@ -7,6 +7,7 @@ import Delete from "../dialogs/delete-user";
import LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import TransferOwnership from "../dialogs/transfer-ownership";
import UserGroups from "../dialogs/user-groups";
import type { HeadplaneUserData } from "../overview";
interface MenuProps {
@@ -15,9 +16,12 @@ interface MenuProps {
currentLink?: string;
isSelf?: boolean;
isOwner?: boolean;
canEditGroups?: boolean;
policyGroups?: string[];
policyHasComments?: boolean;
}
type Modal = "delete" | "reassign" | "link" | "transfer" | null;
type Modal = "delete" | "reassign" | "link" | "transfer" | "groups" | null;
export default function UserMenu({
user,
@@ -25,6 +29,9 @@ export default function UserMenu({
currentLink,
isSelf,
isOwner,
canEditGroups,
policyGroups,
policyHasComments,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@@ -74,6 +81,19 @@ export default function UserMenu({
}}
/>
)}
{modal === "groups" && user.linkedHeadscaleUser && (
<UserGroups
availableGroups={policyGroups ?? []}
policyHasComments={policyHasComments}
displayName={displayName}
groups={user.groups}
isOpen={modal === "groups"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
userName={user.linkedHeadscaleUser.name}
/>
)}
{modal === "transfer" && (
<TransferOwnership
isOpen={modal === "transfer"}
@@ -99,6 +119,9 @@ export default function UserMenu({
<MenuItem onClick={() => setModal("link")}>
{isLinked ? "Change linked user" : "Link Headscale user"}
</MenuItem>
{canEditGroups && user.linkedHeadscaleUser && (
<MenuItem onClick={() => setModal("groups")}>Edit groups</MenuItem>
)}
{isOwner && !isSelf && (
<>
<MenuSeparator />