+ );
+}
diff --git a/app/routes/acls/dialogs/acl-rule.tsx b/app/routes/acls/dialogs/acl-rule.tsx
new file mode 100644
index 0000000..ba6a8c5
--- /dev/null
+++ b/app/routes/acls/dialogs/acl-rule.tsx
@@ -0,0 +1,90 @@
+import { useEffect, useState } from "react";
+
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Link from "~/components/link";
+import Text from "~/components/text";
+import Title from "~/components/title";
+import TokenList from "~/components/token-list";
+import { withDefaultPort, type AclRule } from "~/utils/acl-policy";
+
+interface AclRuleDialogProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ rule?: AclRule;
+ sources: string[];
+ destinations: string[];
+ onSave: (rule: AclRule) => void;
+}
+
+const EMPTY: AclRule = { action: "accept", src: [], dst: [], extra: {} };
+
+export default function AclRuleDialog({
+ isOpen,
+ setIsOpen,
+ rule,
+ sources,
+ destinations,
+ onSave,
+}: AclRuleDialogProps) {
+ const [draft, setDraft] = useState(rule ?? EMPTY);
+
+ useEffect(() => {
+ if (isOpen) {
+ setDraft(rule ? structuredClone(rule) : structuredClone(EMPTY));
+ }
+ }, [isOpen, rule]);
+
+ const isInvalid = draft.src.length === 0 || draft.dst.length === 0;
+
+ return (
+
+ );
+}
diff --git a/app/routes/acls/dialogs/host.tsx b/app/routes/acls/dialogs/host.tsx
new file mode 100644
index 0000000..76a9bbb
--- /dev/null
+++ b/app/routes/acls/dialogs/host.tsx
@@ -0,0 +1,76 @@
+import { useEffect, useState } from "react";
+
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
+import { isValidHostName } from "~/utils/acl-policy";
+
+interface HostDialogProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ name?: string;
+ value?: string;
+ existingNames: string[];
+ onSave: (name: string, value: string) => void;
+}
+
+export default function HostDialog({
+ isOpen,
+ setIsOpen,
+ name,
+ value,
+ existingNames,
+ onSave,
+}: HostDialogProps) {
+ const [draftName, setDraftName] = useState(name ?? "");
+ const [draftValue, setDraftValue] = useState(value ?? "");
+
+ useEffect(() => {
+ if (isOpen) {
+ setDraftName(name ?? "");
+ setDraftValue(value ?? "");
+ }
+ }, [isOpen, name, value]);
+
+ const isDuplicate = draftName !== name && existingNames.includes(draftName);
+ const nameIsInvalid = !isValidHostName(draftName) || isDuplicate;
+ const valueIsInvalid = draftValue.trim().length === 0;
+
+ return (
+
+ );
+}
diff --git a/app/routes/acls/dialogs/named-list.tsx b/app/routes/acls/dialogs/named-list.tsx
new file mode 100644
index 0000000..8111fca
--- /dev/null
+++ b/app/routes/acls/dialogs/named-list.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useState } from "react";
+
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Text from "~/components/text";
+import Title from "~/components/title";
+import TokenList from "~/components/token-list";
+import { isValidGroupName, isValidTagName } from "~/utils/acl-policy";
+
+export type NamedListKind = "group" | "tag";
+
+interface NamedListDialogProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ kind: NamedListKind;
+ // Present when editing, absent when creating a new entry.
+ name?: string;
+ members?: string[];
+ existingNames: string[];
+ suggestions: string[];
+ onSave: (name: string, members: string[]) => void;
+}
+
+const COPY = {
+ group: {
+ title: "group",
+ prefix: "group:",
+ field: "Members",
+ fieldDescription: "Headscale users that belong to this group.",
+ empty: "No members yet",
+ placeholder: "alice@",
+ validate: isValidGroupName,
+ hint: "Group names must start with group: and may only contain lowercase letters, numbers and dashes.",
+ },
+ tag: {
+ title: "tag",
+ prefix: "tag:",
+ field: "Tag owners",
+ fieldDescription: "Users and groups allowed to assign this tag to a node.",
+ empty: "No owners yet",
+ placeholder: "group:ops",
+ validate: isValidTagName,
+ hint: "Tag names must start with tag: and may only contain lowercase letters, numbers and dashes.",
+ },
+} as const;
+
+export default function NamedListDialog({
+ isOpen,
+ setIsOpen,
+ kind,
+ name,
+ members,
+ existingNames,
+ suggestions,
+ onSave,
+}: NamedListDialogProps) {
+ const copy = COPY[kind];
+ const [draftName, setDraftName] = useState(name ?? copy.prefix);
+ const [draftMembers, setDraftMembers] = useState(members ?? []);
+
+ useEffect(() => {
+ if (isOpen) {
+ setDraftName(name ?? copy.prefix);
+ setDraftMembers(members ? [...members] : []);
+ }
+ }, [isOpen, name, members, copy.prefix]);
+
+ const trimmedName = draftName.trim();
+ const isDuplicate = trimmedName !== name && existingNames.includes(trimmedName);
+ const nameIsInvalid = !copy.validate(trimmedName) || isDuplicate;
+ // The field opens pre-filled with the `group:`/`tag:` prefix, which is not a
+ // valid name yet. Saving stays blocked, but nothing is flagged until it is edited.
+ const isPristine = trimmedName.length === 0 || trimmedName === copy.prefix;
+ const showNameError = !isPristine && nameIsInvalid;
+
+ return (
+
+ );
+}
diff --git a/app/routes/acls/dialogs/ssh-rule.tsx b/app/routes/acls/dialogs/ssh-rule.tsx
new file mode 100644
index 0000000..0ff4975
--- /dev/null
+++ b/app/routes/acls/dialogs/ssh-rule.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from "react";
+
+import Dialog, { DialogPanel } from "~/components/dialog";
+import Input from "~/components/input";
+import Link from "~/components/link";
+import Select from "~/components/select";
+import Text from "~/components/text";
+import Title from "~/components/title";
+import TokenList from "~/components/token-list";
+import { KNOWN_SSH_ACTIONS, type SshRule } from "~/utils/acl-policy";
+
+interface SshRuleDialogProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+ rule?: SshRule;
+ sources: string[];
+ destinations: string[];
+ onSave: (rule: SshRule) => void;
+}
+
+const EMPTY: SshRule = { action: "accept", src: [], dst: [], users: [], extra: {} };
+const SSH_USERS = ["root", "autogroup:nonroot"];
+
+export default function SshRuleDialog({
+ isOpen,
+ setIsOpen,
+ rule,
+ sources,
+ destinations,
+ onSave,
+}: SshRuleDialogProps) {
+ const [draft, setDraft] = useState(rule ?? EMPTY);
+
+ useEffect(() => {
+ if (isOpen) {
+ setDraft(rule ? structuredClone(rule) : structuredClone(EMPTY));
+ }
+ }, [isOpen, rule]);
+
+ const isInvalid = draft.src.length === 0 || draft.dst.length === 0 || draft.users.length === 0;
+
+ return (
+
+ );
+}
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index 51206f9..9aec407 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -1,5 +1,14 @@
-import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
-import { Suspense, lazy, useEffect, useState } from "react";
+import {
+ AlertCircle,
+ Construction,
+ Eye,
+ FlaskConical,
+ Pencil,
+ Shield,
+ TagsIcon,
+} from "lucide-react";
+import type { ReactNode } from "react";
+import { Suspense, lazy, useEffect, useMemo, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
@@ -10,12 +19,21 @@ import Notice from "~/components/notice";
import PageError from "~/components/page-error";
import { Tabs, TabsList, TabsPanel, TabsTab } from "~/components/tabs";
import { isApiError } from "~/server/headscale/api/error-client";
+import {
+ parsePolicy,
+ policyDestinations,
+ policySources,
+ serializePolicy,
+ type Policy,
+} from "~/utils/acl-policy";
import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import Fallback from "./components/fallback";
+import RulesEditor from "./components/rules-editor";
+import TagsGroupsEditor from "./components/tags-groups-editor";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
@@ -27,12 +45,24 @@ const LazyDiffer = lazy(() =>
export const loader = aclLoader;
export const action = aclAction;
-export default function Page({ loaderData: { access, writable, policy } }: Route.ComponentProps) {
+export default function Page({
+ loaderData: { access, writable, policy, users, tagUsage },
+}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
+ const parsed = useMemo(() => parsePolicy(codePolicy), [codePolicy]);
+ const sources = useMemo(
+ () => (parsed.ok ? policySources(parsed.policy, users) : []),
+ [parsed, users],
+ );
+ const destinations = useMemo(
+ () => (parsed.ok ? policyDestinations(parsed.policy, users) : []),
+ [parsed, users],
+ );
+
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
@@ -52,6 +82,37 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
}
}, [fetcher.data]);
+ // The structured editors round-trip through the policy text, so the file
+ // editor, the diff view and Save all work off one source of truth.
+ function applyPolicy(next: Policy) {
+ setCodePolicy(serializePolicy(next));
+ }
+
+ function structuredPanel(render: (value: Policy) => ReactNode) {
+ if (!parsed.ok) {
+ return (
+
+
+ The policy could not be parsed ({parsed.error}). Fix it in the Edit file{" "}
+ tab and the visual editor will come back.
+
+
+ );
+ }
+
+ return (
+
+ {parsed.hasComments ? (
+
+ This policy contains comments. Saving a change made in the visual editor rewrites the
+ policy and drops them.
+
+ ) : null}
+ {render(parsed.policy)}
+
+ );
+ }
+
return (
{!access ? (
@@ -86,8 +147,20 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
"An unknown error occurred while trying to update the ACL policy."}
) : undefined}
-
+
+
+