mirror of
https://github.com/tale/headplane.git
synced 2026-09-01 17:58:25 +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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user