mirror of
https://github.com/tale/headplane.git
synced 2026-08-30 08:49:30 +00:00
feat(ui): structured editor for ACL rules, tags and groups (#608)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user