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 />
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import Dialog, { DialogPanel } from "~/components/dialog";
import Link from "~/components/link";
import Text from "~/components/text";
import Title from "~/components/title";
import TokenList from "~/components/token-list";
import { isValidGroupName } from "~/utils/acl-policy";
interface UserGroupsProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
// The Headscale username, which is what the ACL policy references.
userName: string;
displayName: string;
groups: string[];
availableGroups: string[];
// Whether the stored policy contains HuJSON comments, which saving drops.
policyHasComments?: boolean;
}
export default function UserGroups({
isOpen,
setIsOpen,
userName,
displayName,
groups,
availableGroups,
policyHasComments,
}: UserGroupsProps) {
const fetcher = useFetcher<{ message?: string; error?: string }>();
const submittingRef = useRef(false);
const [selected, setSelected] = useState([...groups]);
const error = fetcher.data?.error;
const isSubmitting = fetcher.state !== "idle";
useEffect(() => {
if (isOpen) {
setSelected([...groups]);
}
}, [isOpen, groups]);
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
submittingRef.current = false;
if (!fetcher.data.error) {
setIsOpen(false);
}
}
}, [fetcher.data, fetcher.state]);
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) {
return;
}
setIsOpen(open);
}}
>
<DialogPanel
isDisabled={isSubmitting}
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData();
form.set("action_id", "update_user_groups");
form.set("user_name", userName);
form.set("groups", selected.join(","));
fetcher.submit(form, { method: "POST" });
}}
>
<Title>Edit ACL groups for {displayName}</Title>
<Text>
Groups live in the ACL policy, not in Headscale. Changing them here rewrites the{" "}
<code className="font-mono">groups</code> section of your policy. See the{" "}
<Link external styled to="https://tailscale.com/kb/1018/acls">
Tailscale ACL guide
</Link>{" "}
for details.
</Text>
{policyHasComments ? (
<p className="mt-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/20 dark:text-amber-300">
Your policy contains comments. Saving here rewrites the policy and drops them.
</p>
) : null}
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
) : null}
<TokenList
emptyText="This user is not in any group"
isDisabled={isSubmitting}
label="Groups"
onChange={setSelected}
placeholder="group:example"
suggestions={availableGroups}
validate={isValidGroupName}
values={selected}
/>
</DialogPanel>
</Dialog>
);
}
+42 -1
View File
@@ -13,6 +13,7 @@ import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities, Roles } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import { groupsForUser, parsePolicy } from "~/utils/acl-policy";
import cn from "~/utils/cn";
import log from "~/utils/log";
import { getUserDisplayName } from "~/utils/user";
@@ -36,10 +37,12 @@ export interface HeadplaneUserData {
linkedHeadscaleUser?: User;
machines: Machine[];
profilePicUrl?: string;
groups: string[];
}
export interface UnlinkedHeadscaleUser extends User {
machines: Machine[];
groups: string[];
}
export async function loader({ request, context }: Route.LoaderArgs) {
@@ -66,6 +69,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiUsers: User[] = [];
let nodes: Machine[] = [];
let apiError: string | undefined;
let policyGroups: string[] = [];
let groupsByUser = new Map<string, string[]>();
// `write_policy` is a role capability; `file` mode refuses the write anyway.
let policyWritable = false;
let policyHasComments = false;
try {
const { api } = await getRequestApi(request);
@@ -75,6 +83,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
]);
nodes = nodesSnap.data;
apiUsers = usersSnap.data;
// Groups live in the policy, so an unreadable one just hides the UI.
try {
const { policy, updatedAt } = await api.policy.get();
// Same signal as the Access Control page: null means `file` mode.
policyWritable = updatedAt !== null;
const parsed = parsePolicy(policy);
if (parsed.ok) {
policyHasComments = parsed.hasComments;
policyGroups = Object.keys(parsed.policy.groups).sort();
groupsByUser = new Map(
apiUsers.map((user) => [user.name, groupsForUser(parsed.policy, user.name)]),
);
}
} catch (error) {
log.warn("api", "Failed to read the ACL policy for groups: %s", String(error));
}
} catch (error) {
log.warn("api", "Failed to fetch Headscale API data: %s", String(error));
apiError =
@@ -117,6 +142,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
profilePicUrl: hsUser
? resolveProfilePic(hsUser.email, hsUser.profilePicUrl)
: resolveProfilePic(hp.email ?? undefined),
groups: hsUser ? (groupsByUser.get(hsUser.name) ?? []) : [],
};
});
@@ -129,6 +155,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
...u,
machines: nodes.filter((n) => n.user?.id === u.id),
profilePicUrl: resolveProfilePic(u.email, u.profilePicUrl),
groups: groupsByUser.get(u.name) ?? [],
}));
// Build linkable Headscale users for admin link dialog
@@ -144,6 +171,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return {
writable: writablePermission,
canEditGroups:
writablePermission && auth.can(principal, Capabilities.write_policy) && policyWritable,
policyGroups,
policyHasComments,
currentUserId: isUserPrincipal(principal) ? principal.user.id : undefined,
isOwner,
oidc: config.oidc ? { issuer: config.oidc.issuer } : undefined,
@@ -204,10 +235,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
>
{loaderData.headplaneUsers.map((user) => (
<HeadplaneUserRow
canEditGroups={loaderData.canEditGroups}
isSelf={user.id === loaderData.currentUserId}
isOwner={loaderData.isOwner}
key={user.id}
headscaleUsers={loaderData.headscaleUsersForLink}
policyGroups={loaderData.policyGroups}
policyHasComments={loaderData.policyHasComments}
user={user}
/>
))}
@@ -243,7 +277,14 @@ export default function Page({ loaderData }: Route.ComponentProps) {
)}
>
{loaderData.unlinkedHeadscaleUsers.map((user) => (
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
<HeadscaleUserRow
canEditGroups={loaderData.canEditGroups}
key={user.id}
policyGroups={loaderData.policyGroups}
policyHasComments={loaderData.policyHasComments}
user={user}
writable={loaderData.writable}
/>
))}
</tbody>
</table>
+50
View File
@@ -1,10 +1,12 @@
import { data } from "react-router";
import { authContext, headscaleLiveStoreContext, requestApiContext } from "~/server/context";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import { isValidGroupName, parsePolicy, serializePolicy, setUserGroups } from "~/utils/acl-policy";
import { validateUsername } from "~/utils/user";
import type { Route } from "./+types/overview";
@@ -142,6 +144,54 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return { message: "Headscale user linked successfully" };
}
case "update_user_groups": {
// Group membership lives in the policy, so `write_policy` is needed too.
if (!auth.can(principal, Capabilities.write_policy)) {
throw data("You do not have permission to write to the ACL policy", { status: 403 });
}
const userName = formData.get("user_name")?.toString();
if (!userName) {
throw data("Missing `user_name` in the form data.", { status: 400 });
}
const groups = (formData.get("groups")?.toString() ?? "")
.split(",")
.map((group) => group.trim())
.filter((group) => group.length > 0);
const invalid = groups.filter((group) => !isValidGroupName(group));
if (invalid.length > 0) {
return data({ error: `Invalid group name: ${invalid.join(", ")}` }, 400);
}
const { policy } = await api.policy.get();
const parsed = parsePolicy(policy);
if (!parsed.ok) {
return data({ error: `The ACL policy could not be parsed: ${parsed.error}` }, 400);
}
try {
await api.policy.set(serializePolicy(setUserGroups(parsed.policy, userName, groups)));
} catch (error) {
// Headscale refuses the write in `file` mode. The UI hides the action
// then, but a stale page can still reach this point.
const message = isDataWithApiError(error) ? error.data.rawData : String(error);
if (message.includes("update is disabled")) {
return data(
{
error:
"The ACL policy is read-only. Set `policy.mode` to `database` in your Headscale configuration to edit groups.",
},
403,
);
}
return data({ error: `Could not update the ACL policy: ${message}` }, 500);
}
return { message: "Groups updated successfully" };
}
default:
throw data("Invalid `action_id` provided.", {
status: 400,