diff --git a/.gitignore b/.gitignore index 78303e3a..e91e61b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ +.DS_Store +.vscode node_modules +package-lock.json /.react-router /.cache /build diff --git a/app/components/Dialog.tsx b/app/components/Dialog.tsx index d30d25c4..aa8ec22a 100644 --- a/app/components/Dialog.tsx +++ b/app/components/Dialog.tsx @@ -81,6 +81,7 @@ export interface DialogPanelProps extends AriaDialogProps { onSubmit?: React.FormEventHandler; method?: HTMLFormMethod; isDisabled?: boolean; + confirmLabel?: string; // Anonymous (passed by parent) close?: () => void; @@ -94,6 +95,7 @@ function Panel(props: DialogPanelProps) { close, variant, method = 'POST', + confirmLabel, } = props; const ref = useRef(null); const { dialogProps } = useDialog( @@ -107,6 +109,11 @@ function Panel(props: DialogPanelProps) { return (
{ if (onSubmit) { onSubmit(event); @@ -114,12 +121,7 @@ function Panel(props: DialogPanelProps) { close?.(); }} - method={method ?? 'POST'} ref={ref} - className={cn( - 'outline-hidden rounded-3xl w-full max-w-lg', - 'bg-white dark:bg-headplane-900', - )} > {children} @@ -130,11 +132,11 @@ function Panel(props: DialogPanelProps) { <> )} diff --git a/app/components/Notice.tsx b/app/components/Notice.tsx index 9200019b..3eef356b 100644 --- a/app/components/Notice.tsx +++ b/app/components/Notice.tsx @@ -6,12 +6,15 @@ import { } from 'lucide-react'; import React from 'react'; import Card from '~/components/Card'; +import cn from '~/utils/cn'; export interface NoticeProps { children: React.ReactNode; title?: string; variant?: 'default' | 'error' | 'warning'; icon?: React.ReactElement; + className?: string; + fullWidth?: boolean; } export default function Notice({ @@ -19,9 +22,18 @@ export default function Notice({ title, variant, icon, + className, + fullWidth, }: NoticeProps) { return ( - +
{title ? ( {title} diff --git a/app/components/Tabs.tsx b/app/components/Tabs.tsx index d135d09a..99e35331 100644 --- a/app/components/Tabs.tsx +++ b/app/components/Tabs.tsx @@ -12,9 +12,10 @@ import cn from '~/utils/cn'; export interface TabsProps extends AriaTabListProps { label: string; className?: string; + variant?: 'default' | 'pill'; } -function Tabs({ label, className, ...props }: TabsProps) { +function Tabs({ label, className, variant = 'default', ...props }: TabsProps) { const state = useTabListState(props); const ref = useRef(null); @@ -23,15 +24,20 @@ function Tabs({ label, className, ...props }: TabsProps) {
{[...state.collection].map((item) => ( - + ))}
@@ -42,9 +48,10 @@ function Tabs({ label, className, ...props }: TabsProps) { export interface TabsTabProps { item: Node; state: TabListState; + variant: 'default' | 'pill'; } -function Tab({ item, state }: TabsTabProps) { +function Tab({ item, state, variant }: TabsTabProps) { const { key, rendered } = item; const ref = useRef(null); @@ -52,14 +59,26 @@ function Tab({ item, state }: TabsTabProps) { return (
{rendered}
@@ -73,16 +92,24 @@ export interface TabsPanelProps extends AriaTabPanelProps { function TabsPanel({ state, ...props }: TabsPanelProps) { const ref = useRef(null); const { tabPanelProps } = useTabPanel(props, state, ref); + const content = state.selectedItem?.props.children; + + // If there is no panel content for the selected tab (e.g. header toggles), + // don't render the bordered panel container at all. + if (!content) { + return null; + } + return (
- {state.selectedItem?.props.children} + {content}
); } diff --git a/app/components/ToastProvider.tsx b/app/components/ToastProvider.tsx index 9c4df532..b9692911 100644 --- a/app/components/ToastProvider.tsx +++ b/app/components/ToastProvider.tsx @@ -22,26 +22,51 @@ function Toast({ state, ...props }: ToastProps) { ref, ); + const content = props.toast.content; + let variant: 'default' | 'error' | 'warning' = 'default'; + + const contentElement = React.isValidElement(content) + ? (content as React.ReactElement<{ 'data-variant'?: string }>) + : null; + + if ( + contentElement && + typeof contentElement.props['data-variant'] === 'string' + ) { + if (contentElement.props['data-variant'] === 'error') { + variant = 'error'; + } else if (contentElement.props['data-variant'] === 'warning') { + variant = 'warning'; + } + } + + const bgClass = + variant === 'error' + ? 'bg-red-600 dark:bg-red-700' + : variant === 'warning' + ? 'bg-amber-500 dark:bg-amber-600' + : 'bg-headplane-900 dark:bg-headplane-950'; + return (
{props.toast.content}
@@ -60,11 +85,11 @@ function ToastRegion({ state, ...props }: ToastRegionProps) { return (
{state.visibleToasts.map((toast) => ( - + ))}
); diff --git a/app/components/Tooltip.tsx b/app/components/Tooltip.tsx index 73fbed92..010542da 100644 --- a/app/components/Tooltip.tsx +++ b/app/components/Tooltip.tsx @@ -72,6 +72,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) { 'border border-headplane-100 dark:border-headplane-800', className, )} + style={{ width: 'max-content', maxWidth: 600 }} > {props.children} diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index b3b0ffa2..e78f5f05 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -13,18 +13,30 @@ export async function aclAction({ request, context }: Route.ActionArgs) { Capabilities.write_policy, ); if (!check) { - throw data('You do not have permission to write to the ACL policy', { - status: 403, - }); + return data( + { + success: false, + error: 'You do not have permission to write to the ACL policy', + policy: undefined, + updatedAt: undefined, + }, + 403, + ); } // Try to write to the ACL policy via the API or via config file (TODO). const formData = await request.formData(); const policyData = formData.get('policy')?.toString(); if (!policyData) { - throw data('Missing `policy` in the form data.', { - status: 400, - }); + return data( + { + success: false, + error: 'Missing `policy` in the form data.', + policy: undefined, + updatedAt: undefined, + }, + 400, + ); } const api = context.hsApi.getRuntimeClient(session.api_key); @@ -41,7 +53,15 @@ export async function aclAction({ request, context }: Route.ActionArgs) { const rawData = error.data.rawData; // https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11 if (rawData.includes('update is disabled')) { - throw data('Policy is not writable', { status: 403 }); + return data( + { + success: false, + error: 'Policy is not writable', + policy: undefined, + updatedAt: undefined, + }, + 403, + ); } const message = @@ -133,7 +153,25 @@ export async function aclAction({ request, context }: Route.ActionArgs) { } } - // Otherwise, this is a Headscale error that we can just propagate. - throw error; + // Otherwise, this is a Headscale or generic error. Don't crash the route; + // instead, surface a generic error payload that the UI can show in a toast. + console.error(error); + + const message = + error instanceof ResponseError && error.responseObject?.message + ? (error.responseObject.message as string) + : error instanceof Error + ? error.message + : 'An unexpected error occurred while updating the ACL policy.'; + + return data( + { + success: false, + error: `Policy error: ${message}`, + policy: undefined, + updatedAt: undefined, + }, + 500, + ); } } diff --git a/app/routes/acls/components/AccessControlsHeader.tsx b/app/routes/acls/components/AccessControlsHeader.tsx new file mode 100644 index 00000000..63ab737a --- /dev/null +++ b/app/routes/acls/components/AccessControlsHeader.tsx @@ -0,0 +1,66 @@ +import Link from '~/components/Link'; +import Tabs from '~/components/Tabs'; + +export type AclEditorMode = 'visual' | 'json'; + +interface AccessControlsHeaderProps { + mode: AclEditorMode; + onModeChange: (mode: AclEditorMode) => void; +} + +export default function AccessControlsHeader({ + mode, + onModeChange, +}: AccessControlsHeaderProps) { + return ( +
+
+

Access Control List (ACL)

+

+ Control who and which devices are allowed to connect in your network.{' '} + + Learn more + +

+
+
+
+ {/* Visual / JSON mode toggle */} + onModeChange(key as AclEditorMode)} + selectedKey={mode} + variant="pill" + > + + Visual editor + + } + > + {null} + + + JSON editor + + } + > + {null} + + +
+
+
+ ); +} diff --git a/app/routes/acls/components/AccessControlsVisualEditor.tsx b/app/routes/acls/components/AccessControlsVisualEditor.tsx new file mode 100644 index 00000000..fc7bb13d --- /dev/null +++ b/app/routes/acls/components/AccessControlsVisualEditor.tsx @@ -0,0 +1,3537 @@ +import { type ReactNode, useMemo, useState } from 'react'; +import cn from '~/utils/cn'; +import AddGeneralAccessRuleDialog from './AddGeneralAccessRuleDialog'; +import AddGroupDialog, { type NewGroupInput } from './AddGroupDialog'; +import AddHostDialog, { type NewHostInput } from './AddHostDialog'; +import AddTagOwnerDialog, { type NewTagOwnerInput } from './AddTagOwnerDialog'; +import EditGeneralAccessRuleDialog from './EditGeneralAccessRuleDialog'; +import EditGroupDialog from './EditGroupDialog'; +import EditHostDialog from './EditHostDialog'; +import EditTagOwnerDialog from './EditTagOwnerDialog'; +import GeneralAccessRulesPanel, { + type GeneralAccessRule, +} from './GeneralAccessRulesPanel'; +import GroupsPanel, { type GroupEntry } from './GroupsPanel'; +import HostsPanel, { type HostEntry } from './HostsPanel'; +import TagsPanel, { type TagOwnerEntry } from './TagsPanel'; +import TailscaleSshPanel from './TailscaleSshPanel'; + +export type VisualEditorTab = + | 'general-access-rules' + | 'tailscale-ssh' + | 'groups' + | 'tags' + | 'hosts'; + +interface TabConfig { + key: VisualEditorTab; + label: string; + panel: ReactNode; +} + +function extractAclNotes(policy: string): string[] { + // Extract comment lines starting with // directly above each ACL rule + // inside the "acls" array. Each group of consecutive comment lines + // becomes the note for the next rule object. + const lines = policy.split(/\r?\n/); + const notes: string[] = []; + let inAclsSection = false; + let depth = 0; + let pending: string[] = []; + + for (const rawLine of lines) { + const line = rawLine; + + if (!inAclsSection) { + if (/"acls"\s*:\s*\[/.test(line)) { + inAclsSection = true; + // We just entered the acls array. + depth = 1; + } + continue; + } + + const openBrackets = (line.match(/\[/g) ?? []).length; + const closeBrackets = (line.match(/\]/g) ?? []).length; + depth += openBrackets - closeBrackets; + + if (depth <= 0) { + break; + } + + const trimmed = line.trim(); + + if (trimmed.startsWith('//')) { + pending.push(trimmed.replace(/^\/\/\s?/, '').trim()); + continue; + } + + if (trimmed.startsWith('{')) { + const note = pending.join('\n').trim(); + notes.push(note); + pending = []; + } + } + + return notes; +} + +function deleteAclFromPolicy( + policy: string, + rule: GeneralAccessRule, +): string | null { + const index = Number(rule.id.replace(/^acl-/, '')); + if (!policy || !policy.trim() || Number.isNaN(index) || index < 0) { + return null; + } + + // We want to preserve comments and overall formatting as much as possible. + // Instead of reparsing and re-stringifying the whole document, operate + // directly on the "acls" array text and remove the targeted rule object + // (and its preceding comment block) from the original string. + if (!/"acls"\s*:\s*\[/.test(policy)) { + // For now we only support comment-preserving deletion for lowercase "acls". + // If the structure is different, do nothing to avoid rewriting the policy. + return null; + } + + const lines = policy.split(/\r?\n/); + let inAcls = false; + let bracketDepth = 0; + let objectDepth = 0; + let currentRule = -1; + let commentStart: number | null = null; + const ranges: { start: number; end: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inAcls) { + if (/"acls"\s*:\s*\[/.test(line)) { + inAcls = true; + const open = (line.match(/\[/g) ?? []).length; + const close = (line.match(/\]/g) ?? []).length; + bracketDepth += open - close; + } + continue; + } + + const openBrackets = (line.match(/\[/g) ?? []).length; + const closeBrackets = (line.match(/\]/g) ?? []).length; + bracketDepth += openBrackets - closeBrackets; + + const trimmed = line.trim(); + + if (bracketDepth <= 0) { + // We've exited the "acls" array. + break; + } + + // Track comment blocks that precede a rule object. + if (objectDepth === 0 && trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + + // Starting a new rule object. + if (objectDepth === 0 && openBraces > 0 && trimmed.startsWith('{')) { + currentRule += 1; + const start = commentStart ?? i; + ranges[currentRule] = { start, end: i }; + commentStart = null; + } + + objectDepth += openBraces - closeBraces; + + // Finished a rule object (objectDepth just returned to 0). + if ( + objectDepth === 0 && + openBraces + closeBraces > 0 && + ranges[currentRule] + ) { + let end = i; + const trimmedEnd = lines[end].trim(); + + // If the closing line doesn't end with a comma, there may be a comma + // on the following line; include it to keep array commas valid. + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < lines.length && lines[j].trim() === '') { + j++; + } + if (j < lines.length && lines[j].trim() === ',') { + end = j; + } + } + + ranges[currentRule].end = end; + } + } + + const target = ranges[index]; + if (!target) { + return null; + } + + const nextLines = [ + ...lines.slice(0, target.start), + ...lines.slice(target.end + 1), + ]; + + return nextLines.join('\n'); +} + +function addAclToPolicy( + policy: string, + input: { + source: string; + destination: string; + protocol: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + // As with deletion, only support comment-preserving insertion + // for the lowercase "acls" structure. + if (!/"acls"\s*:\s*\[/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let aclsStart = -1; + let inAcls = false; + let bracketDepth = 0; + let arrayEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inAcls) { + if (/"acls"\s*:\s*\[/.test(line)) { + inAcls = true; + aclsStart = i; + const open = (line.match(/\[/g) ?? []).length; + const close = (line.match(/\]/g) ?? []).length; + bracketDepth += open - close; + + if (bracketDepth <= 0) { + arrayEnd = i; + break; + } + } + continue; + } + + const openBrackets = (line.match(/\[/g) ?? []).length; + const closeBrackets = (line.match(/\]/g) ?? []).length; + bracketDepth += openBrackets - closeBrackets; + + if (bracketDepth <= 0) { + arrayEnd = i; + break; + } + } + + if (aclsStart === -1 || arrayEnd === -1) { + return null; + } + + // Determine indentation for entries and whether there are existing rules. + let entryIndent = ''; + let hasExistingRules = false; + + for (let i = aclsStart + 1; i < arrayEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + + if (trimmed.startsWith('{')) { + hasExistingRules = true; + entryIndent = line.slice(0, line.indexOf(trimmed)); + break; + } + } + + if (!entryIndent) { + const aclsLine = lines[aclsStart]; + const match = /^(\s*)/.exec(aclsLine); + const baseIndent = match ? match[1] : ''; + entryIndent = `${baseIndent} `; + } + + const toArray = (value: string): string[] => + value + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + const src = toArray(input.source); + const dst = toArray(input.destination); + const proto = input.protocol.trim(); + + // If there are existing rules, ensure the previous last rule line + // ends with a comma so that the new rule becomes a valid next element, + // without introducing a standalone comma line that would break JSON. + if (hasExistingRules) { + for (let i = arrayEnd - 1; i > aclsStart; i--) { + const candidate = lines[i]; + const trimmedCandidate = candidate.trim(); + if (!trimmedCandidate || trimmedCandidate.startsWith('//')) { + continue; + } + + if (!trimmedCandidate.endsWith(',')) { + const match = /(.*\S)(\s*)$/.exec(candidate); + if (match) { + lines[i] = `${match[1]},${match[2]}`; + } else { + lines[i] = `${trimmedCandidate},`; + } + } + break; + } + } + + const newLines: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + newLines.push(`${entryIndent}// ${trimmed}`); + } + } + + const aclObject: any = { action: 'accept' }; + + if (src.length) { + aclObject.src = src; + } + if (dst.length) { + aclObject.dst = dst; + } + if (proto) { + aclObject.proto = proto; + } + + // Emit ACL objects in single-line form, with spaces for readability on keys + // while preserving any colons that appear inside string values (e.g. "tag:none"). + const json = JSON.stringify(aclObject) + .replace(/"([^"]+)":/g, '"$1": ') + .replace(/,"/g, ', "'); + + if (hasExistingRules) { + // New rule is appended as the last element in the array, so it does not + // need a trailing comma (the previous rule line already has one). + newLines.push(`${entryIndent}${json}`); + } else { + // Single-entry ACL arrays use a trailing comma style. + newLines.push(`${entryIndent}${json},`); + } + + const before = lines.slice(0, arrayEnd); + const after = lines.slice(arrayEnd); + + const insertion: string[] = []; + insertion.push(...newLines); + + const resultLines = [...before, ...insertion, ...after]; + + return resultLines.join('\n'); +} + +function updateAclInPolicy( + policy: string, + index: number, + input: { + source: string; + destination: string; + protocol: string; + note: string; + }, +): string | null { + if ( + !policy || + !policy.trim() || + Number.isNaN(index) || + index < 0 || + !/"acls"\s*:\s*\[/.test(policy) + ) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let inAcls = false; + let bracketDepth = 0; + let objectDepth = 0; + let aclsStart = -1; + let arrayEnd = -1; + let currentRule = -1; + let commentStart: number | null = null; + const ranges: { start: number; end: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inAcls) { + if (/"acls"\s*:\s*\[/.test(line)) { + inAcls = true; + aclsStart = i; + const open = (line.match(/\[/g) ?? []).length; + const close = (line.match(/\]/g) ?? []).length; + bracketDepth += open - close; + } + continue; + } + + const openBrackets = (line.match(/\[/g) ?? []).length; + const closeBrackets = (line.match(/\]/g) ?? []).length; + bracketDepth += openBrackets - closeBrackets; + + const trimmed = line.trim(); + + if (bracketDepth <= 0) { + arrayEnd = i; + break; + } + + // Track comment blocks that precede a rule object. + if (objectDepth === 0 && trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + + // Starting a new rule object. + if (objectDepth === 0 && openBraces > 0 && trimmed.startsWith('{')) { + currentRule += 1; + const start = commentStart ?? i; + ranges[currentRule] = { start, end: i }; + commentStart = null; + } + + objectDepth += openBraces - closeBraces; + + // Finished a rule object (objectDepth just returned to 0). + if ( + objectDepth === 0 && + openBraces + closeBraces > 0 && + ranges[currentRule] + ) { + let end = i; + const trimmedEnd = lines[end].trim(); + + // If the closing line doesn't end with a comma, there may be a comma + // on the following line; include it to keep array commas valid. + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < lines.length && lines[j].trim() === '') { + j++; + } + if (j < lines.length && lines[j].trim() === ',') { + end = j; + } + } + + ranges[currentRule].end = end; + } + } + + if (aclsStart === -1 || arrayEnd === -1) { + return null; + } + + const target = ranges[index]; + if (!target) { + return null; + } + + // Derive indentation for this entry from its opening brace, or fall back + // to a default based on the "acls" line if needed. + let entryIndent = ''; + for (let i = target.start; i <= target.end; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + + if (trimmed.startsWith('{')) { + entryIndent = line.slice(0, line.indexOf(trimmed)); + break; + } + } + + if (!entryIndent) { + const aclsLine = lines[aclsStart]; + const match = /^(\s*)/.exec(aclsLine); + const baseIndent = match ? match[1] : ''; + entryIndent = `${baseIndent} `; + } + + const toArray = (value: string): string[] => + value + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + const src = toArray(input.source); + const dst = toArray(input.destination); + const proto = input.protocol.trim(); + + // Build the replacement block (comments + JSON object) without worrying + // about the trailing comma yet. + const replacementCore: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + replacementCore.push(`${entryIndent}// ${trimmed}`); + } + } + + const aclObject: any = { action: 'accept' }; + + if (src.length) { + aclObject.src = src; + } + if (dst.length) { + aclObject.dst = dst; + } + if (proto) { + aclObject.proto = proto; + } + + // Emit ACL objects in single-line form, with spaces for readability on keys, + // and always with a trailing comma (HuJSON style), while preserving any + // colons inside string values (e.g. "tag:none"). + const json = JSON.stringify(aclObject) + .replace(/"([^"]+)":/g, '"$1": ') + .replace(/,"/g, ', "'); + replacementCore.push(`${entryIndent}${json},`); + + const replacement: string[] = []; + + replacement.push(...replacementCore); + + const nextLines = [ + ...lines.slice(0, target.start), + ...replacement, + ...lines.slice(target.end + 1), + ]; + + return nextLines.join('\n'); +} + +function reorderAclsInPolicy( + policy: string, + orderedRuleIds: number[], +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"acls"\s*:\s*\[/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let inAcls = false; + let bracketDepth = 0; + let objectDepth = 0; + let aclsStart = -1; + let arrayEnd = -1; + let currentRule = -1; + let commentStart: number | null = null; + const ranges: { start: number; end: number }[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inAcls) { + if (/"acls"\s*:\s*\[/.test(line)) { + inAcls = true; + aclsStart = i; + const open = (line.match(/\[/g) ?? []).length; + const close = (line.match(/\]/g) ?? []).length; + bracketDepth += open - close; + } + continue; + } + + const openBrackets = (line.match(/\[/g) ?? []).length; + const closeBrackets = (line.match(/\]/g) ?? []).length; + bracketDepth += openBrackets - closeBrackets; + + const trimmed = line.trim(); + + if (bracketDepth <= 0) { + arrayEnd = i; + break; + } + + // Track comment blocks that precede a rule object. + if (objectDepth === 0 && trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + + // Starting a new rule object. + if (objectDepth === 0 && openBraces > 0 && trimmed.startsWith('{')) { + currentRule += 1; + const start = commentStart ?? i; + ranges[currentRule] = { start, end: i }; + commentStart = null; + } + + objectDepth += openBraces - closeBraces; + + // Finished a rule object (objectDepth just returned to 0). + if ( + objectDepth === 0 && + openBraces + closeBraces > 0 && + ranges[currentRule] + ) { + let end = i; + const trimmedEnd = lines[end].trim(); + + // If the closing line doesn't end with a comma, there may be a comma + // on the following line; include it to keep array commas valid. + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < lines.length && lines[j].trim() === '') { + j++; + } + if (j < lines.length && lines[j].trim() === ',') { + end = j; + } + } + + ranges[currentRule].end = end; + } + } + + if (aclsStart === -1 || arrayEnd === -1 || ranges.length === 0) { + return null; + } + + const order = orderedRuleIds + .map((idx) => + Number.isNaN(idx) || idx < 0 || idx >= ranges.length ? null : idx, + ) + .filter((idx): idx is number => idx !== null); + + if (order.length === 0) { + return null; + } + + const firstStart = ranges[0].start; + const lastEnd = ranges[ranges.length - 1].end; + + const before = lines.slice(0, firstStart); + const after = lines.slice(lastEnd + 1); + + const buildCleanBlock = (range: { start: number; end: number }): string[] => { + const blockLines = lines.slice(range.start, range.end + 1); + + // Remove trailing comma from the block (either on its own line or on the closing brace line) + let lastNonEmpty = blockLines.length - 1; + while (lastNonEmpty >= 0 && blockLines[lastNonEmpty].trim() === '') { + lastNonEmpty--; + } + if (lastNonEmpty < 0) { + return blockLines; + } + + const lastLine = blockLines[lastNonEmpty]; + const trimmedLast = lastLine.trim(); + + if (trimmedLast === ',') { + // Comma on its own line + blockLines.splice(lastNonEmpty, 1); + } else if (trimmedLast.endsWith(',')) { + // Comma at end of the line, usually after a closing brace + const commaIndex = lastLine.lastIndexOf(','); + if (commaIndex !== -1) { + blockLines[lastNonEmpty] = + lastLine.slice(0, commaIndex) + lastLine.slice(commaIndex + 1); + } + } + + return blockLines; + }; + + const reorderedBlocks: string[] = []; + + order.forEach((idx, position) => { + const block = buildCleanBlock(ranges[idx]); + const isLastBlock = position === order.length - 1; + + block.forEach((line, lineIndex) => { + const isLastLineOfBlock = lineIndex === block.length - 1; + + if (!isLastBlock && isLastLineOfBlock) { + // For all but the last block, ensure a trailing comma at the end of the block. + const trimmedEnd = line.trimEnd(); + if (trimmedEnd.endsWith('}')) { + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + reorderedBlocks.push(`${match[1]},${match[2]}`); + } else { + reorderedBlocks.push(`${trimmedEnd},`); + } + } else { + // Fallback: keep the line as-is and add a comma-only line with matching indent. + reorderedBlocks.push(line); + const indentMatch = /^(\s*)/.exec(line); + const indent = indentMatch ? indentMatch[1] : ''; + reorderedBlocks.push(`${indent},`); + } + } else { + reorderedBlocks.push(line); + } + }); + }); + + const resultLines = [...before, ...reorderedBlocks, ...after]; + + return resultLines.join('\n'); +} + +function reorderHostsInPolicy( + policy: string, + orderedHostIds: number[], +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"hosts"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let hostsStart = -1; + let inHosts = false; + let braceDepth = 0; + let hostsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inHosts) { + if (/"hosts"\s*:\s*{/.test(line)) { + inHosts = true; + hostsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + hostsEnd = i; + break; + } + } + + if (hostsStart === -1 || hostsEnd === -1) { + return null; + } + + const entries: { start: number; end: number }[] = []; + let commentStart: number | null = null; + + for (let i = hostsStart + 1; i < hostsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < hostsEnd && lines[j].trim() === '') { + j++; + } + if (j < hostsEnd && lines[j].trim() === ',') { + end = j; + } + } + + entries.push({ start, end }); + commentStart = null; + continue; + } + + commentStart = null; + } + + if (entries.length === 0) { + return null; + } + + const order = orderedHostIds + .map((idx) => + Number.isNaN(idx) || idx < 0 || idx >= entries.length ? null : idx, + ) + .filter((idx): idx is number => idx !== null); + + if (order.length === 0) { + return null; + } + + const firstStart = entries[0].start; + const lastEnd = entries[entries.length - 1].end; + + const before = lines.slice(0, firstStart); + const after = lines.slice(lastEnd + 1); + + const buildCleanBlock = (range: { start: number; end: number }): string[] => { + const blockLines = lines.slice(range.start, range.end + 1); + + // Remove trailing comma from the block (either on its own line or on the property line) + let lastNonEmpty = blockLines.length - 1; + while (lastNonEmpty >= 0 && blockLines[lastNonEmpty].trim() === '') { + lastNonEmpty--; + } + if (lastNonEmpty < 0) { + return blockLines; + } + + const lastLine = blockLines[lastNonEmpty]; + const trimmedLast = lastLine.trim(); + + if (trimmedLast === ',') { + // Comma on its own line + blockLines.splice(lastNonEmpty, 1); + } else if (trimmedLast.endsWith(',')) { + // Comma at end of the line, usually after the property + const commaIndex = lastLine.lastIndexOf(','); + if (commaIndex !== -1) { + blockLines[lastNonEmpty] = + lastLine.slice(0, commaIndex) + lastLine.slice(commaIndex + 1); + } + } + + return blockLines; + }; + + const reorderedBlocks: string[] = []; + + order.forEach((idx, position) => { + const block = buildCleanBlock(entries[idx]); + const isLastBlock = position === order.length - 1; + + block.forEach((line, lineIndex) => { + const isLastLineOfBlock = lineIndex === block.length - 1; + + if (!isLastBlock && isLastLineOfBlock) { + // For all but the last block, ensure a trailing comma at the end of the block. + const trimmedEnd = line.trimEnd(); + if (trimmedEnd.endsWith('"')) { + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + reorderedBlocks.push(`${match[1]},${match[2]}`); + } else { + reorderedBlocks.push(`${trimmedEnd},`); + } + } else { + // Fallback: keep the line as-is and add a comma-only line with matching indent. + reorderedBlocks.push(line); + const indentMatch = /^(\s*)/.exec(line); + const indent = indentMatch ? indentMatch[1] : ''; + reorderedBlocks.push(`${indent},`); + } + } else { + reorderedBlocks.push(line); + } + }); + }); + + const resultLines = [...before, ...reorderedBlocks, ...after]; + + return resultLines.join('\n'); +} + +function extractHostNotes(policy: string): Record { + const lines = policy.split(/\r?\n/); + const notes: Record = {}; + let inHosts = false; + let braceDepth = 0; + let pending: string[] = []; + + for (const rawLine of lines) { + const line = rawLine; + + if (!inHosts) { + if (/"hosts"\s*:\s*{/.test(line)) { + inHosts = true; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + break; + } + + const trimmed = line.trim(); + + if (trimmed.startsWith('//')) { + const text = trimmed.replace(/^\/\/\s?/, '').trim(); + if (text) { + pending.push(text); + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const name = match[1]; + const note = pending.join('\n').trim(); + if (note) { + notes[name] = note; + } + pending = []; + continue; + } + + if (trimmed) { + pending = []; + } + } + + return notes; +} + +function addHostToPolicy( + policy: string, + input: { + name: string; + address: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"hosts"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let hostsStart = -1; + let inHosts = false; + let braceDepth = 0; + let hostsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inHosts) { + if (/"hosts"\s*:\s*{/.test(line)) { + inHosts = true; + hostsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + + if (braceDepth <= 0) { + hostsEnd = i; + break; + } + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + hostsEnd = i; + break; + } + } + + if (hostsStart === -1 || hostsEnd === -1) { + return null; + } + + let entryIndent = ''; + let hasExistingHosts = false; + + for (let i = hostsStart + 1; i < hostsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + + if (/^"[^"]+"\s*:/.test(trimmed)) { + hasExistingHosts = true; + entryIndent = line.slice(0, line.indexOf(trimmed)); + break; + } + } + + if (!entryIndent) { + const hostsLine = lines[hostsStart]; + const match = /^(\s*)/.exec(hostsLine); + const baseIndent = match ? match[1] : ''; + entryIndent = `${baseIndent} `; + } + + const name = input.name.trim(); + const address = input.address.trim(); + if (!name || !address) { + return null; + } + + const newLines: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + newLines.push(`${entryIndent}// ${trimmed}`); + } + } + + const propLine = `${entryIndent}${JSON.stringify(name)}: ${JSON.stringify(address)}`; + newLines.push(propLine); + + if (hasExistingHosts && newLines.length > 0) { + // Instead of rewriting the previous host line, insert a standalone comma + // line before the new host block. In JSON/HuJSON the comma token belongs + // to the previous entry and can legally appear on its own line. + const commaIndent = entryIndent; + newLines.unshift(`${commaIndent},`); + } + + const before = lines.slice(0, hostsEnd); + const after = lines.slice(hostsEnd); + + const insertion: string[] = []; + insertion.push(...newLines); + + const resultLines = [...before, ...insertion, ...after]; + + return resultLines.join('\n'); +} + +function updateHostInPolicy( + policy: string, + originalName: string, + input: { + name: string; + address: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim() || !/"hosts"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let hostsStart = -1; + let inHosts = false; + let braceDepth = 0; + let hostsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inHosts) { + if (/"hosts"\s*:\s*{/.test(line)) { + inHosts = true; + hostsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + hostsEnd = i; + break; + } + } + + if (hostsStart === -1 || hostsEnd === -1) { + return null; + } + + let targetStart = -1; + let targetEnd = -1; + let commentStart: number | null = null; + let propLineIndex = -1; + + for (let i = hostsStart + 1; i < hostsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const name = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < hostsEnd && lines[j].trim() === '') { + j++; + } + if (j < hostsEnd && lines[j].trim() === ',') { + end = j; + } + } + + if (name === originalName) { + targetStart = start; + targetEnd = end; + propLineIndex = i; + break; + } + + commentStart = null; + continue; + } + + commentStart = null; + } + + if (targetStart === -1 || targetEnd === -1 || propLineIndex === -1) { + return null; + } + + const name = input.name.trim(); + const address = input.address.trim(); + if (!name || !address) { + return null; + } + + const propLine = lines[propLineIndex]; + const trimmedProp = propLine.trim(); + const entryIndent = propLine.slice(0, propLine.indexOf(trimmedProp)) || ''; + + const replacementCore: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + replacementCore.push(`${entryIndent}// ${trimmed}`); + } + } + + const newPropLine = `${entryIndent}${JSON.stringify(name)}: ${JSON.stringify( + address, + )}`; + replacementCore.push(newPropLine); + + let hasCommaSeparateLine = false; + let commaLineIndent = ''; + let hasCommaOnLine = false; + + for (let i = targetEnd; i >= targetStart; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + if (trimmed === ',') { + hasCommaSeparateLine = true; + const indentMatch = /^(\s*)/.exec(line); + commaLineIndent = indentMatch ? indentMatch[1] : ''; + break; + } + + if (trimmed.endsWith(',') && trimmed !== ',') { + hasCommaOnLine = true; + break; + } + + if (trimmed.startsWith('//')) { + break; + } + } + + const replacement: string[] = []; + + if (hasCommaSeparateLine) { + replacement.push(...replacementCore); + replacement.push(`${commaLineIndent},`); + } else if (hasCommaOnLine) { + const coreCopy = [...replacementCore]; + let lastNonEmpty = coreCopy.length - 1; + while (lastNonEmpty >= 0 && coreCopy[lastNonEmpty].trim() === '') { + lastNonEmpty--; + } + for (let i = lastNonEmpty; i >= 0; i--) { + const line = coreCopy[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + if (trimmed.endsWith('"')) { + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + coreCopy[i] = `${match[1]},${match[2]}`; + } else { + coreCopy[i] = `${trimmed},`; + } + break; + } + } + replacement.push(...coreCopy); + } else { + replacement.push(...replacementCore); + } + + const nextLines = [ + ...lines.slice(0, targetStart), + ...replacement, + ...lines.slice(targetEnd + 1), + ]; + + return nextLines.join('\n'); +} + +function deleteHostFromPolicy(policy: string, name: string): string | null { + if (!policy || !policy.trim() || !/"hosts"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let hostsStart = -1; + let inHosts = false; + let braceDepth = 0; + let hostsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inHosts) { + if (/"hosts"\s*:\s*{/.test(line)) { + inHosts = true; + hostsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + hostsEnd = i; + break; + } + } + + if (hostsStart === -1 || hostsEnd === -1) { + return null; + } + + const entries: { name: string; start: number; end: number }[] = []; + let commentStart: number | null = null; + + for (let i = hostsStart + 1; i < hostsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const hostName = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < hostsEnd && lines[j].trim() === '') { + j++; + } + if (j < hostsEnd && lines[j].trim() === ',') { + end = j; + } + } + + entries.push({ name: hostName, start, end }); + commentStart = null; + continue; + } + + commentStart = null; + } + + const target = entries.find((entry) => entry.name === name); + if (!target) { + return null; + } + + const nextLines = [ + ...lines.slice(0, target.start), + ...lines.slice(target.end + 1), + ]; + + return nextLines.join('\n'); +} + +function extractGroupNotes(policy: string): Record { + const lines = policy.split(/\r?\n/); + const notes: Record = {}; + let inGroups = false; + let braceDepth = 0; + let pending: string[] = []; + + for (const rawLine of lines) { + const line = rawLine; + + if (!inGroups) { + if (/"groups"\s*:\s*{/.test(line)) { + inGroups = true; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + break; + } + + const trimmed = line.trim(); + + if (trimmed.startsWith('//')) { + const text = trimmed.replace(/^\/\/\s?/, '').trim(); + if (text) { + pending.push(text); + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const groupName = match[1]; + const note = pending.join('\n').trim(); + if (note) { + notes[groupName] = note; + } + pending = []; + continue; + } + + if (trimmed) { + pending = []; + } + } + + return notes; +} + +function addGroupToPolicy( + policy: string, + input: { + groupName: string; + members: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"groups"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let groupsStart = -1; + let inGroups = false; + let braceDepth = 0; + let groupsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inGroups) { + if (/"groups"\s*:\s*{/.test(line)) { + inGroups = true; + groupsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + + if (braceDepth <= 0) { + groupsEnd = i; + break; + } + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + groupsEnd = i; + break; + } + } + + if (groupsStart === -1 || groupsEnd === -1) { + return null; + } + + let entryIndent = ''; + let hasExistingEntries = false; + + for (let i = groupsStart + 1; i < groupsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + + if (/^"[^"]+"\s*:/.test(trimmed)) { + hasExistingEntries = true; + entryIndent = line.slice(0, line.indexOf(trimmed)); + break; + } + } + + if (!entryIndent) { + const line = lines[groupsStart]; + const match = /^(\s*)/.exec(line); + const baseIndent = match ? match[1] : ''; + entryIndent = `${baseIndent} `; + } + + // If there are existing entries, ensure the previous entry line ends with + // a comma so that the new group can be appended without introducing a + // standalone comma-only line, which can lead to awkward HuJSON formatting. + if (hasExistingEntries) { + for (let i = groupsEnd - 1; i > groupsStart; i--) { + const candidate = lines[i]; + const trimmedCandidate = candidate.trim(); + if (!trimmedCandidate || trimmedCandidate.startsWith('//')) { + continue; + } + + if (!trimmedCandidate.endsWith(',')) { + const match = /(.*\S)(\s*)$/.exec(candidate); + if (match) { + lines[i] = `${match[1]},${match[2]}`; + } else { + lines[i] = `${trimmedCandidate},`; + } + } + break; + } + } + + const groupName = input.groupName.trim(); + const membersRaw = input.members.trim(); + if (!groupName || !membersRaw) { + return null; + } + + const membersList = membersRaw + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (membersList.length === 0) { + return null; + } + + const newLines: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + newLines.push(`${entryIndent}// ${trimmed}`); + } + } + + const propLine = `${entryIndent}${JSON.stringify( + groupName, + )}: ${JSON.stringify(membersList)}`; + newLines.push(propLine); + + const before = lines.slice(0, groupsEnd); + const after = lines.slice(groupsEnd); + + const insertion: string[] = []; + insertion.push(...newLines); + + const resultLines = [...before, ...insertion, ...after]; + + const nextPolicy = resultLines.join('\n'); + + // Normalize the "groups" object formatting using the same logic as + // `reorderGroupsInPolicy`, but keep the existing order of groups. + try { + const normalizedPolicy = nextPolicy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + groups?: Record; + }; + + if (parsed.groups && typeof parsed.groups === 'object') { + const entries = Object.entries(parsed.groups); + const orderedGroupIds = entries.map((_, idx) => idx); + const reordered = reorderGroupsInPolicy(nextPolicy, orderedGroupIds); + if (reordered) { + return reordered; + } + } + // Fall through to returning nextPolicy if normalization fails. + } catch { + // If anything goes wrong, just return the minimally edited policy. + } + + return nextPolicy; +} + +function updateGroupInPolicy( + policy: string, + originalGroupName: string, + input: { + groupName: string; + members: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim() || !/"groups"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let groupsStart = -1; + let inGroups = false; + let braceDepth = 0; + let groupsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inGroups) { + if (/"groups"\s*:\s*{/.test(line)) { + inGroups = true; + groupsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + groupsEnd = i; + break; + } + } + + if (groupsStart === -1 || groupsEnd === -1) { + return null; + } + + let targetStart = -1; + let targetEnd = -1; + let commentStart: number | null = null; + let propLineIndex = -1; + + for (let i = groupsStart + 1; i < groupsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const currentGroupName = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < groupsEnd && lines[j].trim() === '') { + j++; + } + if (j < groupsEnd && lines[j].trim() === ',') { + end = j; + } + } + + if (currentGroupName === originalGroupName) { + targetStart = start; + targetEnd = end; + propLineIndex = i; + break; + } + + commentStart = null; + continue; + } + + commentStart = null; + } + + if (targetStart === -1 || targetEnd === -1 || propLineIndex === -1) { + return null; + } + + const groupName = input.groupName.trim(); + const membersRaw = input.members.trim(); + if (!groupName || !membersRaw) { + return null; + } + + const membersList = membersRaw + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (membersList.length === 0) { + return null; + } + + const propLine = lines[propLineIndex]; + const trimmedProp = propLine.trim(); + const entryIndent = propLine.slice(0, propLine.indexOf(trimmedProp)) || ''; + + const replacementCore: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + replacementCore.push(`${entryIndent}// ${trimmed}`); + } + } + + const newPropLine = `${entryIndent}${JSON.stringify( + groupName, + )}: ${JSON.stringify(membersList)}`; + replacementCore.push(newPropLine); + + let hasCommaSeparateLine = false; + let commaLineIndent = ''; + let hasCommaOnLine = false; + + for (let i = targetEnd; i >= targetStart; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + if (trimmed === ',') { + hasCommaSeparateLine = true; + const indentMatch = /^(\s*)/.exec(line); + commaLineIndent = indentMatch ? indentMatch[1] : ''; + break; + } + + if (trimmed.endsWith(',') && trimmed !== ',') { + hasCommaOnLine = true; + break; + } + + if (trimmed.startsWith('//')) { + break; + } + } + + const replacement: string[] = []; + + if (hasCommaSeparateLine) { + replacement.push(...replacementCore); + replacement.push(`${commaLineIndent},`); + } else if (hasCommaOnLine) { + const coreCopy = [...replacementCore]; + let lastNonEmpty = coreCopy.length - 1; + while (lastNonEmpty >= 0 && coreCopy[lastNonEmpty].trim() === '') { + lastNonEmpty--; + } + for (let i = lastNonEmpty; i >= 0; i--) { + const line = coreCopy[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + coreCopy[i] = `${match[1]},${match[2]}`; + } else { + coreCopy[i] = `${trimmed},`; + } + break; + } + replacement.push(...coreCopy); + } else { + replacement.push(...replacementCore); + } + + const nextLines = [ + ...lines.slice(0, targetStart), + ...replacement, + ...lines.slice(targetEnd + 1), + ]; + + const nextPolicy = nextLines.join('\n'); + + // After updating a group, normalize the "groups" object formatting + // so it matches the canonical HuJSON style used elsewhere. + try { + const normalizedPolicy = nextPolicy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + groups?: Record; + }; + + if (parsed.groups && typeof parsed.groups === 'object') { + const entries = Object.entries(parsed.groups); + const orderedGroupIds = entries.map((_, idx) => idx); + const reordered = reorderGroupsInPolicy(nextPolicy, orderedGroupIds); + if (reordered) { + return reordered; + } + } + } catch { + // Ignore normalization errors and return the minimally edited policy. + } + + return nextPolicy; +} + +function deleteGroupFromPolicy( + policy: string, + groupName: string, +): string | null { + if (!policy || !policy.trim() || !/"groups"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let groupsStart = -1; + let inGroups = false; + let braceDepth = 0; + let groupsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inGroups) { + if (/"groups"\s*:\s*{/.test(line)) { + inGroups = true; + groupsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + groupsEnd = i; + break; + } + } + + if (groupsStart === -1 || groupsEnd === -1) { + return null; + } + + const entries: { groupName: string; start: number; end: number }[] = []; + let commentStart: number | null = null; + + for (let i = groupsStart + 1; i < groupsEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const currentGroupName = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < groupsEnd && lines[j].trim() === '') { + j++; + } + if (j < groupsEnd && lines[j].trim() === ',') { + end = j; + } + } + + entries.push({ groupName: currentGroupName, start, end }); + commentStart = null; + continue; + } + + commentStart = null; + } + + const target = entries.find((entry) => entry.groupName === groupName); + if (!target) { + return null; + } + + const nextLines = [ + ...lines.slice(0, target.start), + ...lines.slice(target.end + 1), + ]; + + return nextLines.join('\n'); +} + +function reorderGroupsInPolicy( + policy: string, + orderedGroupIds: number[], +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"groups"\s*:\s*{/.test(policy)) { + return null; + } + + let parsedGroups: Record | null = null; + try { + const normalizedPolicy = policy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + groups?: Record; + }; + + if (!parsed.groups || typeof parsed.groups !== 'object') { + return null; + } + + parsedGroups = parsed.groups as Record; + } catch { + return null; + } + + const entries = Object.entries(parsedGroups); + if (entries.length === 0) { + return null; + } + + const order = orderedGroupIds + .map((idx) => + Number.isNaN(idx) || idx < 0 || idx >= entries.length ? null : idx, + ) + .filter((idx): idx is number => idx !== null); + + if (order.length === 0) { + return null; + } + + const reorderedEntries = order.map((idx) => entries[idx]); + + const notes = extractGroupNotes(policy); + + const lines = policy.split(/\r?\n/); + + let groupsStart = -1; + let inGroups = false; + let braceDepth = 0; + let groupsEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inGroups) { + if (/"groups"\s*:\s*{/.test(line)) { + inGroups = true; + groupsStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + groupsEnd = i; + break; + } + } + + if (groupsStart === -1 || groupsEnd === -1) { + return null; + } + + const headerLine = lines[groupsStart]; + const footerLine = lines[groupsEnd]; + + const headerIndentMatch = /^(\s*)/.exec(headerLine); + const baseIndent = headerIndentMatch ? headerIndentMatch[1] : ''; + const entryIndent = `${baseIndent} `; + + const bodyLines: string[] = []; + + const regionLines = lines.slice(groupsStart + 1, groupsEnd); + let prefixEnd = -1; + for (let i = 0; i < regionLines.length; i++) { + const trimmed = regionLines[i].trim(); + if (!trimmed || trimmed.startsWith('//')) { + prefixEnd = i; + continue; + } + if (/^"([^"]+)"\s*:/.test(trimmed)) { + break; + } + prefixEnd = i; + } + if (prefixEnd >= 0) { + for (let i = 0; i <= prefixEnd; i++) { + bodyLines.push(regionLines[i]); + } + } + + reorderedEntries.forEach(([groupName, membersValue], idx) => { + const isLast = idx === reorderedEntries.length - 1; + + const membersArray: string[] = []; + if (Array.isArray(membersValue)) { + membersValue.forEach((v) => { + membersArray.push(String(v)); + }); + } else if (typeof membersValue === 'string') { + membersArray.push(membersValue); + } else if (membersValue != null) { + membersArray.push(JSON.stringify(membersValue)); + } + + const entryLines: string[] = []; + + const note = notes[groupName]; + if (note && note.trim()) { + for (const raw of note.split('\n')) { + const t = raw.trim(); + if (!t) continue; + entryLines.push(`${entryIndent}// ${t}`); + } + } + + if (membersArray.length === 0) { + entryLines.push(`${entryIndent}${JSON.stringify(groupName)}: []`); + } else if (membersArray.length === 1) { + entryLines.push( + `${entryIndent}${JSON.stringify(groupName)}: [${JSON.stringify( + membersArray[0], + )}]`, + ); + } else { + entryLines.push(`${entryIndent}${JSON.stringify(groupName)}: [`); + membersArray.forEach((member, memberIndex) => { + const isLastMember = memberIndex === membersArray.length - 1; + const comma = isLastMember ? '' : ','; + entryLines.push(`${entryIndent} ${JSON.stringify(member)}${comma}`); + }); + entryLines.push(`${entryIndent}]`); + } + + if (!isLast) { + let lastIdx = entryLines.length - 1; + while (lastIdx >= 0 && entryLines[lastIdx].trim() === '') { + lastIdx--; + } + if (lastIdx >= 0) { + const line = entryLines[lastIdx]; + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + entryLines[lastIdx] = `${match[1]},${match[2]}`; + } else { + entryLines[lastIdx] = `${line.trimEnd()},`; + } + } + } + + bodyLines.push(...entryLines); + }); + + const before = lines.slice(0, groupsStart); + const after = lines.slice(groupsEnd + 1); + + const resultLines = [ + ...before, + headerLine, + ...bodyLines, + footerLine, + ...after, + ]; + + return resultLines.join('\n'); +} + +function extractTagOwnerNotes(policy: string): Record { + const lines = policy.split(/\r?\n/); + const notes: Record = {}; + let inTagOwners = false; + let braceDepth = 0; + let pending: string[] = []; + + for (const rawLine of lines) { + const line = rawLine; + + if (!inTagOwners) { + if (/"tagOwners"\s*:\s*{/.test(line)) { + inTagOwners = true; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + break; + } + + const trimmed = line.trim(); + + if (trimmed.startsWith('//')) { + const text = trimmed.replace(/^\/\/\s?/, '').trim(); + if (text) { + pending.push(text); + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const tagName = match[1]; + const note = pending.join('\n').trim(); + if (note) { + notes[tagName] = note; + } + pending = []; + continue; + } + + if (trimmed) { + pending = []; + } + } + + return notes; +} + +function addTagOwnerToPolicy( + policy: string, + input: { + tagName: string; + owners: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"tagOwners"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let tagOwnersStart = -1; + let inTagOwners = false; + let braceDepth = 0; + let tagOwnersEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inTagOwners) { + if (/"tagOwners"\s*:\s*{/.test(line)) { + inTagOwners = true; + tagOwnersStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + + if (braceDepth <= 0) { + tagOwnersEnd = i; + break; + } + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + tagOwnersEnd = i; + break; + } + } + + if (tagOwnersStart === -1 || tagOwnersEnd === -1) { + return null; + } + + let entryIndent = ''; + let hasExistingEntries = false; + + for (let i = tagOwnersStart + 1; i < tagOwnersEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + + if (/^"[^"]+"\s*:/.test(trimmed)) { + hasExistingEntries = true; + entryIndent = line.slice(0, line.indexOf(trimmed)); + break; + } + } + + if (!entryIndent) { + const line = lines[tagOwnersStart]; + const match = /^(\s*)/.exec(line); + const baseIndent = match ? match[1] : ''; + entryIndent = `${baseIndent} `; + } + + const tagName = input.tagName.trim(); + const ownersRaw = input.owners.trim(); + if (!tagName || !ownersRaw) { + return null; + } + + const ownersList = ownersRaw + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (ownersList.length === 0) { + return null; + } + + const newLines: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + newLines.push(`${entryIndent}// ${trimmed}`); + } + } + + const propLine = `${entryIndent}${JSON.stringify( + tagName, + )}: ${JSON.stringify(ownersList)}`; + newLines.push(propLine); + + if (hasExistingEntries && newLines.length > 0) { + const commaIndent = entryIndent; + newLines.unshift(''); + newLines.unshift(`${commaIndent},`); + } + + const before = lines.slice(0, tagOwnersEnd); + const after = lines.slice(tagOwnersEnd); + + const insertion: string[] = []; + insertion.push(...newLines); + + const resultLines = [...before, ...insertion, ...after]; + + const nextPolicy = resultLines.join('\n'); + + // Normalize the "tagOwners" object formatting using the same logic as + // `reorderTagOwnersInPolicy`, but keep the existing order of tag owners. + try { + const normalizedPolicy = nextPolicy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + tagOwners?: Record; + }; + + if (parsed.tagOwners && typeof parsed.tagOwners === 'object') { + const entries = Object.entries(parsed.tagOwners); + const orderedTagOwnerIds = entries.map((_, idx) => idx); + const reordered = reorderTagOwnersInPolicy( + nextPolicy, + orderedTagOwnerIds, + ); + if (reordered) { + return reordered; + } + } + // Fall through to returning nextPolicy if normalization fails. + } catch { + // If anything goes wrong, just return the minimally edited policy. + } + + return nextPolicy; +} + +function updateTagOwnerInPolicy( + policy: string, + originalTagName: string, + input: { + tagName: string; + owners: string; + note: string; + }, +): string | null { + if (!policy || !policy.trim() || !/"tagOwners"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let tagOwnersStart = -1; + let inTagOwners = false; + let braceDepth = 0; + let tagOwnersEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inTagOwners) { + if (/"tagOwners"\s*:\s*{/.test(line)) { + inTagOwners = true; + tagOwnersStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + tagOwnersEnd = i; + break; + } + } + + if (tagOwnersStart === -1 || tagOwnersEnd === -1) { + return null; + } + + let targetStart = -1; + let targetEnd = -1; + let commentStart: number | null = null; + let propLineIndex = -1; + + for (let i = tagOwnersStart + 1; i < tagOwnersEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const tagName = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < tagOwnersEnd && lines[j].trim() === '') { + j++; + } + if (j < tagOwnersEnd && lines[j].trim() === ',') { + end = j; + } + } + + if (tagName === originalTagName) { + targetStart = start; + targetEnd = end; + propLineIndex = i; + break; + } + + commentStart = null; + continue; + } + + commentStart = null; + } + + if (targetStart === -1 || targetEnd === -1 || propLineIndex === -1) { + return null; + } + + const tagName = input.tagName.trim(); + const ownersRaw = input.owners.trim(); + if (!tagName || !ownersRaw) { + return null; + } + + const ownersList = ownersRaw + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + + if (ownersList.length === 0) { + return null; + } + + const propLine = lines[propLineIndex]; + const trimmedProp = propLine.trim(); + const entryIndent = propLine.slice(0, propLine.indexOf(trimmedProp)) || ''; + + const replacementCore: string[] = []; + + if (input.note && input.note.trim()) { + for (const rawLine of input.note.split('\n')) { + const trimmed = rawLine.trim(); + if (!trimmed) { + continue; + } + replacementCore.push(`${entryIndent}// ${trimmed}`); + } + } + + const newPropLine = `${entryIndent}${JSON.stringify( + tagName, + )}: ${JSON.stringify(ownersList)}`; + replacementCore.push(newPropLine); + + let hasCommaSeparateLine = false; + let commaLineIndent = ''; + let hasCommaOnLine = false; + + for (let i = targetEnd; i >= targetStart; i--) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + if (trimmed === ',') { + hasCommaSeparateLine = true; + const indentMatch = /^(\s*)/.exec(line); + commaLineIndent = indentMatch ? indentMatch[1] : ''; + break; + } + + if (trimmed.endsWith(',') && trimmed !== ',') { + hasCommaOnLine = true; + break; + } + + if (trimmed.startsWith('//')) { + break; + } + } + + const replacement: string[] = []; + + if (hasCommaSeparateLine) { + replacement.push(...replacementCore); + replacement.push(`${commaLineIndent},`); + } else if (hasCommaOnLine) { + const coreCopy = [...replacementCore]; + let lastNonEmpty = coreCopy.length - 1; + while (lastNonEmpty >= 0 && coreCopy[lastNonEmpty].trim() === '') { + lastNonEmpty--; + } + for (let i = lastNonEmpty; i >= 0; i--) { + const line = coreCopy[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//')) { + continue; + } + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + coreCopy[i] = `${match[1]},${match[2]}`; + } else { + coreCopy[i] = `${trimmed},`; + } + break; + } + replacement.push(...coreCopy); + } else { + replacement.push(...replacementCore); + } + + const nextLines = [ + ...lines.slice(0, targetStart), + ...replacement, + ...lines.slice(targetEnd + 1), + ]; + + const nextPolicy = nextLines.join('\n'); + + // After updating a tag owner, normalize the "tagOwners" object formatting + // so it matches the canonical HuJSON style used elsewhere. + try { + const normalizedPolicy = nextPolicy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + tagOwners?: Record; + }; + + if (parsed.tagOwners && typeof parsed.tagOwners === 'object') { + const entries = Object.entries(parsed.tagOwners); + const orderedTagOwnerIds = entries.map((_, idx) => idx); + const reordered = reorderTagOwnersInPolicy( + nextPolicy, + orderedTagOwnerIds, + ); + if (reordered) { + return reordered; + } + } + } catch { + // Ignore normalization errors and return the minimally edited policy. + } + + return nextPolicy; +} + +function deleteTagOwnerFromPolicy( + policy: string, + tagName: string, +): string | null { + if (!policy || !policy.trim() || !/"tagOwners"\s*:\s*{/.test(policy)) { + return null; + } + + const lines = policy.split(/\r?\n/); + + let tagOwnersStart = -1; + let inTagOwners = false; + let braceDepth = 0; + let tagOwnersEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inTagOwners) { + if (/"tagOwners"\s*:\s*{/.test(line)) { + inTagOwners = true; + tagOwnersStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + tagOwnersEnd = i; + break; + } + } + + if (tagOwnersStart === -1 || tagOwnersEnd === -1) { + return null; + } + + const entries: { tagName: string; start: number; end: number }[] = []; + let commentStart: number | null = null; + + for (let i = tagOwnersStart + 1; i < tagOwnersEnd; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (trimmed.startsWith('//')) { + if (commentStart === null) { + commentStart = i; + } + continue; + } + + const match = trimmed.match(/^"([^"]+)"\s*:/); + if (match) { + const currentTagName = match[1]; + + const start = commentStart ?? i; + let end = i; + + const trimmedEnd = lines[end].trim(); + if (!trimmedEnd.endsWith(',')) { + let j = end + 1; + while (j < tagOwnersEnd && lines[j].trim() === '') { + j++; + } + if (j < tagOwnersEnd && lines[j].trim() === ',') { + end = j; + } + } + + entries.push({ tagName: currentTagName, start, end }); + commentStart = null; + continue; + } + + commentStart = null; + } + + const target = entries.find((entry) => entry.tagName === tagName); + if (!target) { + return null; + } + + const nextLines = [ + ...lines.slice(0, target.start), + ...lines.slice(target.end + 1), + ]; + + return nextLines.join('\n'); +} + +function reorderTagOwnersInPolicy( + policy: string, + orderedTagOwnerIds: number[], +): string | null { + if (!policy || !policy.trim()) { + return null; + } + + if (!/"tagOwners"\s*:\s*{/.test(policy)) { + return null; + } + + // First, parse a normalized JSON view of tagOwners so we can get the + // canonical list of keys and values in their current order. + let parsedTagOwners: Record | null = null; + try { + const normalizedPolicy = policy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + tagOwners?: Record; + }; + + if (!parsed.tagOwners || typeof parsed.tagOwners !== 'object') { + return null; + } + + parsedTagOwners = parsed.tagOwners as Record; + } catch { + return null; + } + + const entries = Object.entries(parsedTagOwners); + if (entries.length === 0) { + return null; + } + + const order = orderedTagOwnerIds + .map((idx) => + Number.isNaN(idx) || idx < 0 || idx >= entries.length ? null : idx, + ) + .filter((idx): idx is number => idx !== null); + + if (order.length === 0) { + return null; + } + + const reorderedEntries = order.map((idx) => entries[idx]); + + // Extract per-tag notes from the original HuJSON so we can re-attach them + // above each tag owner entry after reordering. + const notes = extractTagOwnerNotes(policy); + + const lines = policy.split(/\r?\n/); + + // Locate the "tagOwners" object region in the original text. + let tagOwnersStart = -1; + let inTagOwners = false; + let braceDepth = 0; + let tagOwnersEnd = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + if (!inTagOwners) { + if (/"tagOwners"\s*:\s*{/.test(line)) { + inTagOwners = true; + tagOwnersStart = i; + const open = (line.match(/{/g) ?? []).length; + const close = (line.match(/}/g) ?? []).length; + braceDepth += open - close; + } + continue; + } + + const openBraces = (line.match(/{/g) ?? []).length; + const closeBraces = (line.match(/}/g) ?? []).length; + braceDepth += openBraces - closeBraces; + + if (braceDepth <= 0) { + tagOwnersEnd = i; + break; + } + } + + if (tagOwnersStart === -1 || tagOwnersEnd === -1) { + return null; + } + + const headerLine = lines[tagOwnersStart]; + const footerLine = lines[tagOwnersEnd]; + + const headerIndentMatch = /^(\s*)/.exec(headerLine); + const baseIndent = headerIndentMatch ? headerIndentMatch[1] : ''; + const entryIndent = `${baseIndent} `; + + const bodyLines: string[] = []; + + // Preserve any leading comments/blank lines that appeared between the + // "tagOwners" header line and the first property. + const regionLines = lines.slice(tagOwnersStart + 1, tagOwnersEnd); + let prefixEnd = -1; + for (let i = 0; i < regionLines.length; i++) { + const trimmed = regionLines[i].trim(); + if (!trimmed || trimmed.startsWith('//')) { + prefixEnd = i; + continue; + } + if (/^"([^"]+)"\s*:/.test(trimmed)) { + break; + } + prefixEnd = i; + } + if (prefixEnd >= 0) { + for (let i = 0; i <= prefixEnd; i++) { + bodyLines.push(regionLines[i]); + } + } + + reorderedEntries.forEach(([tagName, ownersValue], idx) => { + const isLast = idx === reorderedEntries.length - 1; + + const ownersArray: string[] = []; + if (Array.isArray(ownersValue)) { + ownersValue.forEach((v) => { + ownersArray.push(String(v)); + }); + } else if (typeof ownersValue === 'string') { + ownersArray.push(ownersValue); + } else if (ownersValue != null) { + ownersArray.push(JSON.stringify(ownersValue)); + } + + const entryLines: string[] = []; + + const note = notes[tagName]; + if (note && note.trim()) { + for (const raw of note.split('\n')) { + const t = raw.trim(); + if (!t) continue; + entryLines.push(`${entryIndent}// ${t}`); + } + } + + if (ownersArray.length === 0) { + entryLines.push(`${entryIndent}${JSON.stringify(tagName)}: []`); + } else if (ownersArray.length === 1) { + entryLines.push( + `${entryIndent}${JSON.stringify(tagName)}: [${JSON.stringify( + ownersArray[0], + )}]`, + ); + } else { + entryLines.push(`${entryIndent}${JSON.stringify(tagName)}: [`); + ownersArray.forEach((owner, ownerIndex) => { + const isLastOwner = ownerIndex === ownersArray.length - 1; + const comma = isLastOwner ? '' : ','; + entryLines.push(`${entryIndent} ${JSON.stringify(owner)}${comma}`); + }); + entryLines.push(`${entryIndent}]`); + } + + // Add a trailing comma after this entry if it's not the last one, + // keeping the comma on the last non-empty line of the entry. + if (!isLast) { + let lastIdx = entryLines.length - 1; + while (lastIdx >= 0 && entryLines[lastIdx].trim() === '') { + lastIdx--; + } + if (lastIdx >= 0) { + const line = entryLines[lastIdx]; + const match = /(.*\S)(\s*)$/.exec(line); + if (match) { + entryLines[lastIdx] = `${match[1]},${match[2]}`; + } else { + entryLines[lastIdx] = `${line.trimEnd()},`; + } + } + } + + bodyLines.push(...entryLines); + }); + + const before = lines.slice(0, tagOwnersStart); + const after = lines.slice(tagOwnersEnd + 1); + + const resultLines = [ + ...before, + headerLine, + ...bodyLines, + footerLine, + ...after, + ]; + + return resultLines.join('\n'); +} + +export default function AccessControlsVisualEditor({ + policy, + onChangePolicy, + onSavePolicy, +}: { + policy: string; + onChangePolicy?: (nextPolicy: string) => void; + onSavePolicy?: (nextPolicy: string) => void; +}) { + const [activeTab, setActiveTab] = useState( + 'general-access-rules', + ); + const [editRule, setEditRule] = useState(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [editHost, setEditHost] = useState(null); + const [isEditHostDialogOpen, setIsEditHostDialogOpen] = useState(false); + const [isAddHostDialogOpen, setIsAddHostDialogOpen] = useState(false); + const [editGroup, setEditGroup] = useState(null); + const [isEditGroupDialogOpen, setIsEditGroupDialogOpen] = useState(false); + const [isAddGroupDialogOpen, setIsAddGroupDialogOpen] = useState(false); + const [editTagOwner, setEditTagOwner] = useState(null); + const [isEditTagOwnerDialogOpen, setIsEditTagOwnerDialogOpen] = + useState(false); + const [isAddTagOwnerDialogOpen, setIsAddTagOwnerDialogOpen] = useState(false); + + const generalAccessRules = useMemo(() => { + if (!policy || !policy.trim()) return []; + + try { + // The policy we get back is HuJSON (allows comments, trailing commas, etc.) + // Normalize it to strict JSON before parsing so we can safely read `acls`. + const normalizedPolicy = policy + // Strip line comments starting with // + .replace(/\/\/.*$/gm, '') + // Remove trailing commas before } or ] + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + acls?: Array<{ + src?: string[]; + dst?: string[]; + proto?: string; + }>; + }; + + const aclNotes = extractAclNotes(policy); + + // Headscale-style internal structure: { "acls": [{ "src": [...], "dst": [...], "proto": "tcp" }] } + if (Array.isArray(parsed.acls)) { + return parsed.acls.map((entry, index) => { + const src = entry.src ?? []; + const dst = entry.dst ?? []; + const proto = (entry.proto ?? '').trim(); + + const destinationText = dst.length ? dst.join(', ') : '—'; + + const portParts: string[] = []; + + for (const spec of dst) { + const colonIndex = spec.lastIndexOf(':'); + if (colonIndex !== -1 && colonIndex < spec.length - 1) { + portParts.push(spec.slice(colonIndex + 1)); + } + } + + let portSummary = '—'; + if (portParts.length) { + portSummary = Array.from(new Set(portParts)).join(', '); + } + + let portAndProtocol = portSummary; + if (proto) { + portAndProtocol = + portSummary === '—' ? proto : `${proto} ${portSummary}`; + } + + return { + id: `acl-${index}`, + source: src.length ? src.join(', ') : '—', + destination: destinationText, + portAndProtocol, + note: aclNotes[index] ?? '', + protocol: proto, + }; + }); + } + + // No recognized ACLs structure + return []; + } catch { + // Invalid JSON, don't show any rules in the visual editor. + return []; + } + }, [policy]); + + const hosts = useMemo(() => { + if (!policy || !policy.trim()) return []; + + try { + const normalizedPolicy = policy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + hosts?: Record; + }; + + const hostNotes = extractHostNotes(policy); + + if (parsed.hosts && typeof parsed.hosts === 'object') { + const entries = Object.entries(parsed.hosts); + return entries.map(([name, address], index) => ({ + id: `host-${index}`, + name, + address: String(address), + note: hostNotes[name] ?? '', + })); + } + + return []; + } catch { + return []; + } + }, [policy]); + + const groups = useMemo(() => { + if (!policy || !policy.trim()) return []; + + try { + const normalizedPolicy = policy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + groups?: Record; + }; + + const groupNotes = extractGroupNotes(policy); + + if (parsed.groups && typeof parsed.groups === 'object') { + const entries = Object.entries( + parsed.groups as Record, + ); + return entries.map(([groupName, membersValue], index) => { + let membersArray: string[] = []; + + if (Array.isArray(membersValue)) { + membersArray = membersValue.map((v) => String(v)); + } else if (typeof membersValue === 'string') { + membersArray = [membersValue]; + } else if (membersValue != null) { + membersArray = [JSON.stringify(membersValue)]; + } + + return { + id: `group-${index}`, + groupName, + members: membersArray.join(', '), + note: groupNotes[groupName] ?? '', + }; + }); + } + + return []; + } catch { + return []; + } + }, [policy]); + + const tagOwners = useMemo(() => { + if (!policy || !policy.trim()) return []; + + try { + const normalizedPolicy = policy + .replace(/\/\/.*$/gm, '') + .replace(/,(\s*[}\]])/g, '$1'); + + const parsed = JSON.parse(normalizedPolicy) as { + tagOwners?: Record; + }; + + const tagOwnerNotes = extractTagOwnerNotes(policy); + + if (parsed.tagOwners && typeof parsed.tagOwners === 'object') { + const entries = Object.entries( + parsed.tagOwners as Record, + ); + return entries.map(([tagName, ownersValue], index) => { + let ownersArray: string[] = []; + + if (Array.isArray(ownersValue)) { + ownersArray = ownersValue.map((v) => String(v)); + } else if (typeof ownersValue === 'string') { + ownersArray = [ownersValue]; + } else if (ownersValue != null) { + ownersArray = [JSON.stringify(ownersValue)]; + } + + return { + id: `tag-owner-${index}`, + tagName, + owners: ownersArray.join(', '), + note: tagOwnerNotes[tagName] ?? '', + }; + }); + } + + return []; + } catch { + return []; + } + }, [policy]); + + const tabs: TabConfig[] = [ + { + key: 'general-access-rules', + label: 'General access rules', + panel: ( + { + setIsAddDialogOpen(true); + }} + onDeleteRule={(rule) => { + // Ask for confirmation before deleting an ACL rule from the policy. + const confirmed = window.confirm( + 'Are you sure you want to delete this access rule?', + ); + if (!confirmed) { + return; + } + + const nextPolicy = deleteAclFromPolicy(policy, rule); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + onEditRule={(rule) => { + setEditRule(rule); + setIsEditDialogOpen(true); + }} + onReorderRules={(nextRules) => { + const orderedRuleIds = nextRules + .map((rule) => Number(rule.id.replace(/^acl-/, ''))) + .filter((idx) => !Number.isNaN(idx) && idx >= 0); + + const nextPolicy = reorderAclsInPolicy(policy, orderedRuleIds); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + rules={generalAccessRules} + /> + ), + }, + { + key: 'tailscale-ssh', + label: 'Tailscale SSH', + panel: , + }, + { + key: 'groups', + label: 'Groups', + panel: ( + { + setIsAddGroupDialogOpen(true); + }} + onDeleteGroup={(group) => { + const nextPolicy = deleteGroupFromPolicy(policy, group.groupName); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + onEditGroup={(group) => { + setEditGroup(group); + setIsEditGroupDialogOpen(true); + }} + onReorderGroups={(nextGroups: GroupEntry[]) => { + const orderedGroupIds = nextGroups + .map((group: GroupEntry) => + Number(group.id.replace(/^group-/, '')), + ) + .filter((idx: number) => !Number.isNaN(idx) && idx >= 0); + + const nextPolicy = reorderGroupsInPolicy(policy, orderedGroupIds); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + /> + ), + }, + { + key: 'tags', + label: 'Tags', + panel: ( + { + setIsAddTagOwnerDialogOpen(true); + }} + onDeleteTagOwner={(entry) => { + const nextPolicy = deleteTagOwnerFromPolicy(policy, entry.tagName); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + onEditTagOwner={(entry) => { + setEditTagOwner(entry); + setIsEditTagOwnerDialogOpen(true); + }} + onReorderTagOwners={(nextEntries: TagOwnerEntry[]) => { + const orderedTagIds = nextEntries + .map((entry: TagOwnerEntry) => + Number(entry.id.replace(/^tag-owner-/, '')), + ) + .filter((idx: number) => !Number.isNaN(idx) && idx >= 0); + + const nextPolicy = reorderTagOwnersInPolicy(policy, orderedTagIds); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + tagOwners={tagOwners} + /> + ), + }, + { + key: 'hosts', + label: 'Hosts', + panel: ( + { + setIsAddHostDialogOpen(true); + }} + onDeleteHost={(host) => { + const nextPolicy = deleteHostFromPolicy(policy, host.name); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + onEditHost={(host) => { + setEditHost(host); + setIsEditHostDialogOpen(true); + }} + onReorderHosts={(nextHosts: HostEntry[]) => { + const orderedHostIds = nextHosts + .map((host: HostEntry) => Number(host.id.replace(/^host-/, ''))) + .filter((idx: number) => !Number.isNaN(idx) && idx >= 0); + + const nextPolicy = reorderHostsInPolicy(policy, orderedHostIds); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + /> + ), + }, + ]; + + return ( +
+ {/* Top navigation row (tabs) */} +
+ {tabs.map((tab) => { + const isActive = tab.key === activeTab; + + return ( + + ); + })} +
+ + {/* Active panel */} + {tabs.map((tab) => ( +
+ {tab.panel} +
+ ))} + { + if (!editRule) { + return; + } + + const index = Number(editRule.id.replace(/^acl-/, '')); + const nextPolicy = updateAclInPolicy(policy, index, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + rule={editRule} + setIsOpen={setIsEditDialogOpen} + /> + { + const nextPolicy = addAclToPolicy(policy, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsAddDialogOpen} + /> + { + if (!editHost) { + return; + } + + const nextPolicy = updateHostInPolicy(policy, editHost.name, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsEditHostDialogOpen} + /> + { + const nextPolicy = addHostToPolicy(policy, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsAddHostDialogOpen} + /> + { + if (!editGroup) { + return; + } + + const nextPolicy = updateGroupInPolicy( + policy, + editGroup.groupName, + input, + ); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsEditGroupDialogOpen} + /> + { + const nextPolicy = addGroupToPolicy(policy, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsAddGroupDialogOpen} + /> + { + if (!editTagOwner) { + return; + } + + const nextPolicy = updateTagOwnerInPolicy( + policy, + editTagOwner.tagName, + input, + ); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsEditTagOwnerDialogOpen} + /> + { + const nextPolicy = addTagOwnerToPolicy(policy, input); + if (!nextPolicy) { + return; + } + + if (onChangePolicy) { + onChangePolicy(nextPolicy); + } + + if (onSavePolicy) { + onSavePolicy(nextPolicy); + } + }} + setIsOpen={setIsAddTagOwnerDialogOpen} + /> +
+ ); +} diff --git a/app/routes/acls/components/AddGeneralAccessRuleDialog.tsx b/app/routes/acls/components/AddGeneralAccessRuleDialog.tsx new file mode 100644 index 00000000..63d5220e --- /dev/null +++ b/app/routes/acls/components/AddGeneralAccessRuleDialog.tsx @@ -0,0 +1,113 @@ +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import Dialog from '~/components/Dialog'; +import Input from '~/components/Input'; + +export type NewGeneralAccessRuleInput = { + source: string; + destination: string; + // Raw protocol value written to the ACL JSON (proto field), e.g. "tcp". + // Leave empty to allow any protocol. + protocol: string; + note: string; +}; + +interface AddGeneralAccessRuleDialogProps { + isOpen: boolean; + setIsOpen: (open: boolean) => void; + onAddRule: (input: NewGeneralAccessRuleInput) => void; +} + +export default function AddGeneralAccessRuleDialog({ + isOpen, + setIsOpen, + onAddRule, +}: AddGeneralAccessRuleDialogProps) { + const [source, setSource] = useState(''); + const [destination, setDestination] = useState(''); + const [protocol, setProtocol] = useState(''); + const [note, setNote] = useState(''); + + // Reset local state whenever the dialog is opened/closed + useEffect(() => { + if (!isOpen) { + setSource(''); + setDestination(''); + setProtocol(''); + setNote(''); + } + }, [isOpen]); + + const isConfirmDisabled = !source.trim() || !destination.trim(); + + return ( + + ) => { + event.preventDefault(); + + onAddRule({ + source: source.trim(), + destination: destination.trim(), + protocol: protocol.trim(), + note: note.trim(), + }); + }} + variant="normal" + > +
+ Add rule +

+ Define a new general access rule. The rule will be added to the ACL + policy JSON, preserving comments and formatting. +

+ +
+ setSource(value)} + value={source} + /> + + setDestination(value)} + value={destination} + /> + + setProtocol(value)} + value={protocol} + /> + +
+ +