acls: refactor editor into React components
Some checks failed
Build / native (push) Has been cancelled
Build / nix (push) Has been cancelled
Automated / flake-inputs (push) Has been cancelled

Extract the ACL editor surface (was 759 lines of mixed Astro/Alpine/HTML
in src/pages/acls.astro) into a focused React component tree under
src/components/acls/:

- ACLEditor.tsx           — top-level state + layout
- RuleEditor.tsx          — per-rule fields and reordering
- SourceSelector.tsx      — source-side identity picker
- DestinationSelector.tsx — destination + port picker
- types.ts                — shared TS shapes
- validation.ts           — pre-submit validation helpers

acls.astro becomes a thin SSR wrapper that mounts <ACLEditor />.

- astro.config.mjs: add @astrojs/react integration
- package.json: pull in @astrojs/react, react@18, react-dom@18, types,
  ip-address + is-cidr (CIDR/IP validation in source/dest editors),
  lucide-react (icons)
- description string: replace escaped 🤠 sequence with the literal char
This commit is contained in:
Ryan Malloy 2026-06-06 15:08:51 -06:00
parent 21175c5b7a
commit 36c1b18724
11 changed files with 1306 additions and 646 deletions

View File

@ -1,4 +1,5 @@
import node from '@astrojs/node'; import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind'; import tailwind from '@astrojs/tailwind';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
@ -14,6 +15,8 @@ export default defineConfig({
// Tailwind configuration // Tailwind configuration
applyBaseStyles: false, // We'll handle base styles ourselves applyBaseStyles: false, // We'll handle base styles ourselves
}), }),
// React islands — used by the ACL editor under /acls.
react(),
], ],
// Content collections for live VPN data are enabled by default in Astro 4.x // Content collections for live VPN data are enabled by default in Astro 4.x

View File

@ -1,7 +1,7 @@
{ {
"name": "heady", "name": "heady",
"version": "1.0.0", "version": "1.0.0",
"description": "\ud83e\udd20 Heady - Strategic VPN management that's actually awesome to use!", "description": "🤠 Heady - Strategic VPN management that's actually awesome to use!",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@ -23,6 +23,7 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/node": "^8.3.4", "@astrojs/node": "^8.3.4",
"@astrojs/react": "^3.6.3",
"@astrojs/tailwind": "^5.1.2", "@astrojs/tailwind": "^5.1.2",
"@kubernetes/client-node": "^1.3.0", "@kubernetes/client-node": "^1.3.0",
"@libsql/client": "0.15.12", "@libsql/client": "0.15.12",
@ -36,10 +37,15 @@
"dotenv": "17.2.1", "dotenv": "17.2.1",
"drizzle-orm": "0.44.4", "drizzle-orm": "0.44.4",
"guacamole-lite": "^1.2.0", "guacamole-lite": "^1.2.0",
"ip-address": "^9.0.5",
"is-cidr": "^5.0.3",
"jose": "6.1.0", "jose": "6.1.0",
"lucide": "^0.456.0", "lucide": "^0.456.0",
"lucide-react": "^0.456.0",
"mime": "^4.0.7", "mime": "^4.0.7",
"openid-client": "6.7.0", "openid-client": "6.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "3.3.1", "tailwind-merge": "3.3.1",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"ulidx": "2.4.1", "ulidx": "2.4.1",
@ -52,6 +58,8 @@
"@biomejs/biome": "^2.2.0", "@biomejs/biome": "^2.2.0",
"@tailwindcss/vite": "^4.1.12", "@tailwindcss/vite": "^4.1.12",
"@types/node": "^24.3.0", "@types/node": "^24.3.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^2.1.8", "@vitest/coverage-v8": "^2.1.8",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.4",

207
pnpm-lock.yaml generated
View File

@ -14,6 +14,9 @@ importers:
'@astrojs/node': '@astrojs/node':
specifier: ^8.3.4 specifier: ^8.3.4
version: 8.3.4(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2)) version: 8.3.4(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2))
'@astrojs/react':
specifier: ^3.6.3
version: 3.6.3(@types/node@24.3.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(lightningcss@1.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.39.0)
'@astrojs/tailwind': '@astrojs/tailwind':
specifier: ^5.1.2 specifier: ^5.1.2
version: 5.1.5(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2))(tailwindcss@3.4.17) version: 5.1.5(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2))(tailwindcss@3.4.17)
@ -53,18 +56,33 @@ importers:
guacamole-lite: guacamole-lite:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) version: 1.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
ip-address:
specifier: ^9.0.5
version: 9.0.5
is-cidr:
specifier: ^5.0.3
version: 5.1.1
jose: jose:
specifier: 6.1.0 specifier: 6.1.0
version: 6.1.0 version: 6.1.0
lucide: lucide:
specifier: ^0.456.0 specifier: ^0.456.0
version: 0.456.0 version: 0.456.0
lucide-react:
specifier: ^0.456.0
version: 0.456.0(react@18.3.1)
mime: mime:
specifier: ^1.6.0 specifier: ^1.6.0
version: 1.6.0 version: 1.6.0
openid-client: openid-client:
specifier: 6.7.0 specifier: 6.7.0
version: 6.7.0 version: 6.7.0
react:
specifier: ^18.3.1
version: 18.3.1
react-dom:
specifier: ^18.3.1
version: 18.3.1(react@18.3.1)
tailwind-merge: tailwind-merge:
specifier: 3.3.1 specifier: 3.3.1
version: 3.3.1 version: 3.3.1
@ -96,6 +114,12 @@ importers:
'@types/node': '@types/node':
specifier: ^24.3.0 specifier: ^24.3.0
version: 24.3.0 version: 24.3.0
'@types/react':
specifier: ^18.3.5
version: 18.3.31
'@types/react-dom':
specifier: ^18.3.0
version: 18.3.7(@types/react@18.3.31)
'@types/ws': '@types/ws':
specifier: ^8.18.1 specifier: ^8.18.1
version: 8.18.1 version: 8.18.1
@ -185,6 +209,15 @@ packages:
resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==} resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==}
engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0}
'@astrojs/react@3.6.3':
resolution: {integrity: sha512-5ihLQDH5Runddug5AZYlnp/Q5T81QxhwnWJXA9rchBAdh11c6UhBbv9Kdk7b2PkXoEU70CGWBP9hSh0VCR58eA==}
engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0}
peerDependencies:
'@types/react': ^17.0.50 || ^18.0.21
'@types/react-dom': ^17.0.17 || ^18.0.6
react: ^17.0.2 || ^18.0.0 || ^19.0.0-beta
react-dom: ^17.0.2 || ^18.0.0 || ^19.0.0-beta
'@astrojs/tailwind@5.1.5': '@astrojs/tailwind@5.1.5':
resolution: {integrity: sha512-1diguZEau7FZ9vIjzE4BwavGdhD3+JkdS8zmibl1ene+EHgIU5hI0NMgRYG3yea+Niaf7cyMwjeWeLvzq/maxg==} resolution: {integrity: sha512-1diguZEau7FZ9vIjzE4BwavGdhD3+JkdS8zmibl1ene+EHgIU5hI0NMgRYG3yea+Niaf7cyMwjeWeLvzq/maxg==}
peerDependencies: peerDependencies:
@ -240,6 +273,10 @@ packages:
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@babel/helper-plugin-utils@7.29.7':
resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -272,6 +309,18 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0-0 '@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx-self@7.29.7':
resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx-source@7.29.7':
resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-react-jsx@7.27.1': '@babel/plugin-transform-react-jsx@7.27.1':
resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==} resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -1154,6 +1203,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rolldown/pluginutils@1.0.0-beta.29': '@rolldown/pluginutils@1.0.0-beta.29':
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==} resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
@ -1473,6 +1525,17 @@ packages:
'@types/node@24.3.0': '@types/node@24.3.0':
resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==} resolution: {integrity: sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
'@types/stream-buffers@3.0.7': '@types/stream-buffers@3.0.7':
resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==} resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==}
@ -1488,6 +1551,12 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
'@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitejs/plugin-vue@6.0.1': '@vitejs/plugin-vue@6.0.1':
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==} resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1898,6 +1967,10 @@ packages:
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
cidr-regex@4.1.3:
resolution: {integrity: sha512-86M1y3ZeQvpZkZejQCcS+IaSWjlDUC+ORP0peScQ4uEUFCZ8bEQVz7NlJHqysoUb6w3zCjx4Mq/8/2RHhMwHYw==}
engines: {node: '>=14'}
cli-boxes@3.0.0: cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -1972,6 +2045,9 @@ packages:
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-uri-to-buffer@4.0.1: data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
@ -2537,6 +2613,10 @@ packages:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
ip-regex@5.0.0:
resolution: {integrity: sha512-fOCG6lhoKKakwv+C6KdsOnGvgXnmgfmp0myi3bcNwj3qfwPAxRKWEuFhvEFF7ceYIz6+1jRZ+yguLFAmUNPEfw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
is-arrayish@0.3.4: is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
@ -2544,6 +2624,10 @@ packages:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'} engines: {node: '>=8'}
is-cidr@5.1.1:
resolution: {integrity: sha512-AwzRMjtJNTPOgm7xuYZ71715z99t+4yRnSnSzgK5err5+heYi4zMuvmpUadaJ28+KCXCQo8CjUrKQZRWSPmqTQ==}
engines: {node: '>=14'}
is-core-module@2.16.1: is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2850,6 +2934,10 @@ packages:
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
loupe@3.1.4: loupe@3.1.4:
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
@ -2860,6 +2948,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.456.0:
resolution: {integrity: sha512-DIIGJqTT5X05sbAsQ+OhA8OtJYyD4NsEMCA/HQW/Y6ToPQ7gwbtujIoeAaup4HpHzV35SQOarKAWH8LYglB6eA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
lucide@0.456.0: lucide@0.456.0:
resolution: {integrity: sha512-/QHD4F0KXx7YECX9bhaac5TMPtR6vSv6qxHLtL61Z6Xp6IdUUsEF1zo6McEoGJw1z9msAx8QiQn6u3g704mGlg==} resolution: {integrity: sha512-/QHD4F0KXx7YECX9bhaac5TMPtR6vSv6qxHLtL61Z6Xp6IdUUsEF1zo6McEoGJw1z9msAx8QiQn6u3g704mGlg==}
@ -3357,6 +3450,19 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
read-cache@1.0.0: read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -3518,6 +3624,9 @@ packages:
safe-buffer@5.2.1: safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
section-matter@1.0.0: section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -3838,6 +3947,9 @@ packages:
resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==}
engines: {node: '>=16'} engines: {node: '>=16'}
ultrahtml@1.6.0:
resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==}
undici-types@6.21.0: undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -4357,6 +4469,26 @@ snapshots:
dependencies: dependencies:
prismjs: 1.30.0 prismjs: 1.30.0
'@astrojs/react@3.6.3(@types/node@24.3.0)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(lightningcss@1.30.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(terser@5.39.0)':
dependencies:
'@types/react': 18.3.31
'@types/react-dom': 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react': 4.7.0(vite@5.4.20(@types/node@24.3.0)(lightningcss@1.30.1)(terser@5.39.0))
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
ultrahtml: 1.6.0
vite: 5.4.20(@types/node@24.3.0)(lightningcss@1.30.1)(terser@5.39.0)
transitivePeerDependencies:
- '@types/node'
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
'@astrojs/tailwind@5.1.5(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2))(tailwindcss@3.4.17)': '@astrojs/tailwind@5.1.5(astro@4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2))(tailwindcss@3.4.17)':
dependencies: dependencies:
astro: 4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2) astro: 4.16.19(@types/node@24.3.0)(lightningcss@1.30.1)(rollup@4.46.3)(terser@5.39.0)(typescript@5.9.2)
@ -4451,6 +4583,8 @@ snapshots:
'@babel/helper-plugin-utils@7.27.1': {} '@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-plugin-utils@7.29.7': {}
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {} '@babel/helper-validator-identifier@7.27.1': {}
@ -4475,6 +4609,16 @@ snapshots:
'@babel/core': 7.28.3 '@babel/core': 7.28.3
'@babel/helper-plugin-utils': 7.27.1 '@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.28.3)':
dependencies:
'@babel/core': 7.28.3
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.28.3)':
dependencies:
'@babel/core': 7.28.3
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.3)': '@babel/plugin-transform-react-jsx@7.27.1(@babel/core@7.28.3)':
dependencies: dependencies:
'@babel/core': 7.28.3 '@babel/core': 7.28.3
@ -5118,6 +5262,8 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.33': '@rolldown/binding-win32-x64-msvc@1.0.0-beta.33':
optional: true optional: true
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rolldown/pluginutils@1.0.0-beta.29': {} '@rolldown/pluginutils@1.0.0-beta.29': {}
'@rolldown/pluginutils@1.0.0-beta.33': {} '@rolldown/pluginutils@1.0.0-beta.33': {}
@ -5422,6 +5568,17 @@ snapshots:
dependencies: dependencies:
undici-types: 7.10.0 undici-types: 7.10.0
'@types/prop-types@15.7.15': {}
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
'@types/react@18.3.31':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
'@types/stream-buffers@3.0.7': '@types/stream-buffers@3.0.7':
dependencies: dependencies:
'@types/node': 24.3.0 '@types/node': 24.3.0
@ -5436,6 +5593,18 @@ snapshots:
'@ungap/structured-clone@1.3.0': {} '@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.20(@types/node@24.3.0)(lightningcss@1.30.1)(terser@5.39.0))':
dependencies:
'@babel/core': 7.28.3
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.28.3)
'@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.28.3)
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.20(@types/node@24.3.0)(lightningcss@1.30.1)(terser@5.39.0)
transitivePeerDependencies:
- supports-color
'@vitejs/plugin-vue@6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2))': '@vitejs/plugin-vue@6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2))':
dependencies: dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29 '@rolldown/pluginutils': 1.0.0-beta.29
@ -5958,6 +6127,10 @@ snapshots:
ci-info@4.3.0: {} ci-info@4.3.0: {}
cidr-regex@4.1.3:
dependencies:
ip-regex: 5.0.0
cli-boxes@3.0.0: {} cli-boxes@3.0.0: {}
cli-cursor@5.0.0: cli-cursor@5.0.0:
@ -6023,6 +6196,8 @@ snapshots:
csstype@3.1.3: {} csstype@3.1.3: {}
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
date-fns@4.1.0: {} date-fns@4.1.0: {}
@ -6566,6 +6741,8 @@ snapshots:
jsbn: 1.1.0 jsbn: 1.1.0
sprintf-js: 1.1.3 sprintf-js: 1.1.3
ip-regex@5.0.0: {}
is-arrayish@0.3.4: is-arrayish@0.3.4:
optional: true optional: true
@ -6573,6 +6750,10 @@ snapshots:
dependencies: dependencies:
binary-extensions: 2.3.0 binary-extensions: 2.3.0
is-cidr@5.1.1:
dependencies:
cidr-regex: 4.1.3
is-core-module@2.16.1: is-core-module@2.16.1:
dependencies: dependencies:
hasown: 2.0.2 hasown: 2.0.2
@ -6818,6 +6999,10 @@ snapshots:
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.1.4: {} loupe@3.1.4: {}
lru-cache@10.2.2: {} lru-cache@10.2.2: {}
@ -6826,6 +7011,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@0.456.0(react@18.3.1):
dependencies:
react: 18.3.1
lucide@0.456.0: {} lucide@0.456.0: {}
magic-string@0.30.17: magic-string@0.30.17:
@ -7475,6 +7664,18 @@ snapshots:
strip-json-comments: 2.0.1 strip-json-comments: 2.0.1
optional: true optional: true
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react-refresh@0.17.0: {}
react@18.3.1:
dependencies:
loose-envify: 1.4.0
read-cache@1.0.0: read-cache@1.0.0:
dependencies: dependencies:
pify: 2.3.0 pify: 2.3.0
@ -7701,6 +7902,10 @@ snapshots:
safe-buffer@5.2.1: safe-buffer@5.2.1:
optional: true optional: true
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
section-matter@1.0.0: section-matter@1.0.0:
dependencies: dependencies:
extend-shallow: 2.0.1 extend-shallow: 2.0.1
@ -8096,6 +8301,8 @@ snapshots:
dependencies: dependencies:
layerr: 3.0.0 layerr: 3.0.0
ultrahtml@1.6.0: {}
undici-types@6.21.0: {} undici-types@6.21.0: {}
undici-types@7.10.0: {} undici-types@7.10.0: {}

View File

@ -0,0 +1,307 @@
// 🤠 Heady ACL Editor (React island)
//
// Forked from the tailscale-acl-editor repo and adapted to the Heady runtime:
// - Receives initial policy from the Astro page (server-rendered).
// - Posts changes to /api/acls (or a configurable endpoint).
// - Preserves unmodeled top-level keys (groups, hosts, ssh, tagOwners…)
// through `_passthrough` so saving a rule edit doesn't drop them.
//
// The editor only structures the `acls` array visually; the raw JSON preview
// shows the merged document, and the import path round-trips any extra
// top-level fields untouched.
import {
AlertCircle,
CheckCircle2,
Download,
Loader2,
Save,
Upload,
X,
} from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import RuleEditor from './RuleEditor';
import type { ACLPolicy, ACLRule } from './types';
interface ACLEditorProps {
/** Initial policy as the server rendered it. */
initialPolicy: ACLPolicy;
/** Endpoint to PATCH on save. Defaults to /api/acls. */
apiUrl?: string;
/** Whether the current user can persist changes (server-decided). */
canEdit?: boolean;
}
const EMPTY_RULE: ACLRule = { action: 'accept', src: [], dst: [] };
/**
* Split a raw policy object into the parts the visual editor manages
* (`acls` + known structured fields) and everything else (`_passthrough`).
* Keeps unknown keys alive across a save round-trip.
*/
function splitPolicy(raw: Record<string, unknown>): ACLPolicy {
const known = new Set([
'acls',
'groups',
'hosts',
'tagOwners',
'ssh',
'autoApprovers',
]);
const passthrough: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw)) {
if (!known.has(k)) passthrough[k] = v;
}
return {
acls: Array.isArray(raw.acls) ? (raw.acls as ACLRule[]) : [],
groups: raw.groups as Record<string, string[]> | undefined,
hosts: raw.hosts as Record<string, string> | undefined,
tagOwners: raw.tagOwners as Record<string, string[]> | undefined,
ssh: Array.isArray(raw.ssh) ? (raw.ssh as unknown[]) : undefined,
autoApprovers: raw.autoApprovers as
| Record<string, unknown>
| undefined,
_passthrough:
Object.keys(passthrough).length > 0 ? passthrough : undefined,
};
}
/** Re-stitch the structured pieces back into a single object for save/export. */
function joinPolicy(policy: ACLPolicy): Record<string, unknown> {
const out: Record<string, unknown> = { ...(policy._passthrough ?? {}) };
if (policy.groups) out.groups = policy.groups;
if (policy.hosts) out.hosts = policy.hosts;
if (policy.tagOwners) out.tagOwners = policy.tagOwners;
out.acls = policy.acls;
if (policy.ssh) out.ssh = policy.ssh;
if (policy.autoApprovers) out.autoApprovers = policy.autoApprovers;
return out;
}
const ACLEditor: React.FC<ACLEditorProps> = ({
initialPolicy,
apiUrl = '/api/acls',
canEdit = true,
}) => {
const [policy, setPolicy] = useState<ACLPolicy>(initialPolicy);
const [originalJson] = useState(() =>
JSON.stringify(joinPolicy(initialPolicy), null, 2),
);
const [importError, setImportError] = useState<string | null>(null);
const [saveState, setSaveState] = useState<
'idle' | 'saving' | 'saved' | 'error'
>('idle');
const [saveError, setSaveError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previewJson = useMemo(
() => JSON.stringify(joinPolicy(policy), null, 2),
[policy],
);
const hasChanges = previewJson !== originalJson;
const addRule = () => {
setPolicy((prev) => ({ ...prev, acls: [...prev.acls, EMPTY_RULE] }));
};
const updateRule = (index: number, rule: ACLRule) => {
setPolicy((prev) => {
const acls = [...prev.acls];
acls[index] = rule;
return { ...prev, acls };
});
};
const removeRule = (index: number) => {
setPolicy((prev) => ({
...prev,
acls: prev.acls.filter((_, i) => i !== index),
}));
};
const handleImportClick = () => fileInputRef.current?.click();
const handleFileChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const raw = JSON.parse(String(e.target?.result));
if (!raw || typeof raw !== 'object') {
throw new Error('Top-level JSON must be an object');
}
setPolicy(splitPolicy(raw as Record<string, unknown>));
setImportError(null);
} catch (err) {
setImportError(
`Failed to import ACL: ${err instanceof Error ? err.message : 'Unknown error'}`,
);
}
};
reader.onerror = () => setImportError('Failed to read the file');
reader.readAsText(file);
if (event.target) event.target.value = '';
};
const handleDownload = () => {
const blob = new Blob([previewJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'heady-acl.json';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleSave = async () => {
if (!canEdit || !hasChanges) return;
setSaveState('saving');
setSaveError(null);
try {
// /api/acls expects FormData (matches the legacy Alpine page).
const form = new FormData();
form.append('policy', previewJson);
const response = await fetch(apiUrl, {
method: 'PATCH',
body: form,
});
const body = await response.json().catch(() => ({}));
if (!response.ok || body.success === false) {
throw new Error(
body.error ?? `HTTP ${response.status} ${response.statusText}`,
);
}
setSaveState('saved');
setTimeout(() => setSaveState('idle'), 2000);
} catch (err) {
setSaveState('error');
setSaveError(
err instanceof Error ? err.message : 'Unknown save error',
);
}
};
return (
<div className="space-y-6">
{/* Action bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#333] pb-4">
<div className="text-sm text-[#999]">
{policy.acls.length} rule{policy.acls.length === 1 ? '' : 's'}
{hasChanges && (
<span className="ml-3 inline-flex items-center rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-300">
unsaved changes
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={addRule}
disabled={!canEdit}
className="rounded-md bg-[#4f8cc9] px-3 py-2 text-sm font-medium text-white hover:bg-[#5f9cd9] disabled:cursor-not-allowed disabled:opacity-50"
>
Add Rule
</button>
<button
onClick={handleImportClick}
className="flex items-center gap-1.5 rounded-md bg-[#333] px-3 py-2 text-sm font-medium text-white hover:bg-[#444]"
>
<Upload size={14} /> Import
</button>
<button
onClick={handleDownload}
className="flex items-center gap-1.5 rounded-md bg-[#333] px-3 py-2 text-sm font-medium text-white hover:bg-[#444]"
>
<Download size={14} /> Export
</button>
<button
onClick={handleSave}
disabled={!canEdit || !hasChanges || saveState === 'saving'}
className="flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saveState === 'saving' ? (
<Loader2 size={14} className="animate-spin" />
) : saveState === 'saved' ? (
<CheckCircle2 size={14} />
) : (
<Save size={14} />
)}
{saveState === 'saving'
? 'Saving…'
: saveState === 'saved'
? 'Saved'
: 'Save'}
</button>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept=".json,application/json"
className="hidden"
/>
</div>
</div>
{/* Inline error banners */}
{(importError || saveError) && (
<div className="flex items-start gap-2 rounded-md border border-red-700 bg-red-900/40 px-4 py-3 text-sm text-red-200">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<div className="flex-1">{importError || saveError}</div>
<button
onClick={() => {
setImportError(null);
setSaveError(null);
setSaveState('idle');
}}
className="text-red-300 hover:text-white"
>
<X size={16} />
</button>
</div>
)}
{/* Rule list */}
<div className="space-y-4">
{policy.acls.length === 0 ? (
<div className="rounded-lg border border-dashed border-[#333] bg-[#1a1a1a] py-12 text-center text-[#999]">
<p>No rules yet.</p>
<button
onClick={addRule}
disabled={!canEdit}
className="mt-3 rounded-md bg-[#4f8cc9] px-3 py-1.5 text-sm font-medium text-white hover:bg-[#5f9cd9] disabled:opacity-50"
>
Add your first rule
</button>
</div>
) : (
policy.acls.map((rule, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rules don't have stable IDs
<RuleEditor
key={index}
rule={rule}
onChange={(updated) => updateRule(index, updated)}
onRemove={() => removeRule(index)}
ruleNumber={index + 1}
/>
))
)}
</div>
{/* JSON preview */}
<details className="rounded-lg border border-[#333] bg-[#1a1a1a]">
<summary className="cursor-pointer select-none px-4 py-3 text-sm font-medium text-[#ccc] hover:bg-[#222]">
Policy JSON Preview
</summary>
<pre className="overflow-auto border-t border-[#333] bg-[#0c0c0c] p-4 text-xs text-[#4f8cc9]">
{previewJson}
</pre>
</details>
</div>
);
};
export default ACLEditor;

View File

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { DestinationEntry, DestinationType } from './types';
import { PlusCircle, X, AlertCircle } from 'lucide-react';
import { isValidDestinationValue } from './validation';
interface DestinationSelectorProps {
destinations: DestinationEntry[];
onChange: (destinations: DestinationEntry[]) => void;
}
const DestinationSelector: React.FC<DestinationSelectorProps> = ({ destinations, onChange }) => {
const [newDestType, setNewDestType] = useState<DestinationType>('any');
const [newDestValue, setNewDestValue] = useState<string>('*');
const [newDestPorts, setNewDestPorts] = useState<string>('*');
const [validationError, setValidationError] = useState<string | null>(null);
const destTypes: { value: DestinationType; label: string }[] = [
{ value: 'any', label: 'Any (*)' },
{ value: 'user', label: 'User' },
{ value: 'group', label: 'Group' },
{ value: 'ip', label: 'Tailscale IP' },
{ value: 'subnet', label: 'Subnet CIDR' },
{ value: 'host', label: 'Host' },
{ value: 'tag', label: 'Tag' },
{ value: 'autogroup', label: 'Autogroup' }
];
const validateAndAddDestination = () => {
// Clear previous error
setValidationError(null);
if (newDestType === 'any') {
onChange([...destinations, { type: 'any', value: '*', ports: '*' }]);
return;
}
if (!newDestValue.trim()) {
setValidationError('Value cannot be empty');
return;
}
// Validate based on destination type and port
if (!isValidDestinationValue(newDestType, newDestValue, newDestPorts)) {
// Determine if the error is with the destination or port
if (newDestPorts !== '*' && !isValidDestinationValue(newDestType, newDestValue, '*')) {
setValidationError(`Invalid ${newDestType} format`);
} else {
setValidationError('Invalid port format');
}
return;
}
// If validation passes, add the destination
onChange([
...destinations,
{
type: newDestType,
value: newDestValue.trim(),
ports: newDestPorts.trim() || '*'
}
]);
setNewDestValue('');
setNewDestPorts('*');
};
const removeDestination = (index: number) => {
onChange(destinations.filter((_, i) => i !== index));
};
const handleDestTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const type = e.target.value as DestinationType;
setNewDestType(type);
setValidationError(null);
// Set default value based on type
if (type === 'any') {
setNewDestValue('*');
} else if (type === 'group') {
setNewDestValue('group:');
} else if (type === 'tag') {
setNewDestValue('tag:');
} else if (type === 'autogroup') {
setNewDestValue('autogroup:');
} else {
setNewDestValue('');
}
};
const getDestinationDisplay = (dest: DestinationEntry): string => {
if (dest.type === 'any') return '*:*';
return `${dest.value}:${dest.ports}`;
};
return (
<div>
<div className="flex flex-wrap gap-2 mb-2">
{destinations.map((dest, index) => (
<div
key={index}
className="flex items-center bg-[#2a5545] text-white px-3 py-1 rounded-full"
>
<span className="mr-1">{getDestinationDisplay(dest)}</span>
<button
onClick={() => removeDestination(index)}
className="text-[#ccc] hover:text-white"
>
<X size={14} />
</button>
</div>
))}
{destinations.length === 0 && (
<div className="text-[#999] italic">No destinations added (required)</div>
)}
</div>
{validationError && (
<div className="flex items-center text-red-500 text-sm mb-2">
<AlertCircle size={14} className="mr-1" />
{validationError}
</div>
)}
<div className="flex gap-2">
<select
className="p-2 border border-[#444] bg-[#333] text-white rounded-md"
value={newDestType}
onChange={handleDestTypeChange}
>
{destTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
{newDestType !== 'any' && (
<>
<input
type="text"
className={`flex-1 p-2 border ${validationError && validationError.includes(newDestType) ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
value={newDestValue}
onChange={(e) => {
setNewDestValue(e.target.value);
setValidationError(null);
}}
placeholder={`Enter ${newDestType} value`}
/>
<input
type="text"
className={`w-32 p-2 border ${validationError && validationError.includes('port') ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
value={newDestPorts}
onChange={(e) => {
setNewDestPorts(e.target.value);
setValidationError(null);
}}
placeholder="Ports"
/>
</>
)}
<button
onClick={validateAndAddDestination}
className="bg-[#4f8cc9] hover:bg-[#5f9cd9] text-white px-3 py-2 rounded-md flex items-center"
>
<PlusCircle size={18} />
</button>
</div>
<div className="text-xs text-[#999] mt-1">
{newDestType === 'user' && "Example: user@example.com"}
{newDestType === 'group' && "Example: group:engineering"}
{newDestType === 'ip' && "Example: 100.101.102.103"}
{newDestType === 'subnet' && "Example: 192.168.1.0/24"}
{newDestType === 'host' && "Example: my-host"}
{newDestType === 'tag' && "Example: tag:production"}
{newDestType === 'autogroup' && "Example: autogroup:internet"}
{newDestType !== 'any' && "Ports: * (any), 22 (single), 80,443 (multiple), 1000-2000 (range)"}
</div>
</div>
);
};
export default DestinationSelector;

View File

@ -0,0 +1,101 @@
import React from 'react';
import { ACLRule, Protocol, SourceEntry, DestinationEntry } from './types';
import SourceSelector from './SourceSelector';
import DestinationSelector from './DestinationSelector';
import { Trash2 } from 'lucide-react';
interface RuleEditorProps {
rule: ACLRule;
onChange: (rule: ACLRule) => void;
onRemove: () => void;
ruleNumber: number;
}
const RuleEditor: React.FC<RuleEditorProps> = ({ rule, onChange, onRemove, ruleNumber }) => {
const protocols: Protocol[] = ['tcp', 'udp', 'icmp', 'sctp', 'igmp', 'esp', 'ah', 'gre', 'ipip', 'dccp', 'all'];
const handleProtoChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value as Protocol | '';
onChange({
...rule,
proto: value === '' ? undefined : value as Protocol
});
};
const handleSourcesChange = (sources: SourceEntry[]) => {
onChange({
...rule,
src: sources
});
};
const handleDestinationsChange = (destinations: DestinationEntry[]) => {
onChange({
...rule,
dst: destinations
});
};
return (
<div className="border border-[#333] rounded-lg p-4 bg-[#222] shadow-sm">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">Rule #{ruleNumber}</h3>
<button
onClick={onRemove}
className="text-[#999] hover:text-white"
title="Remove rule"
>
<Trash2 size={18} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-[#999] mb-1">Action</label>
<select
className="w-full p-2 border border-[#444] bg-[#333] text-white rounded-md"
value={rule.action}
disabled
>
<option value="accept">accept</option>
</select>
<p className="text-xs text-[#777] mt-1">
Tailscale only supports 'accept' actions (deny by default)
</p>
</div>
<div>
<label className="block text-sm font-medium text-[#999] mb-1">Protocol (optional)</label>
<select
className="w-full p-2 border border-[#444] bg-[#333] text-white rounded-md"
value={rule.proto || ''}
onChange={handleProtoChange}
>
<option value="">All protocols</option>
{protocols.map(proto => (
<option key={proto} value={proto}>{proto}</option>
))}
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-[#999] mb-1">Sources (src)</label>
<SourceSelector
sources={rule.src}
onChange={handleSourcesChange}
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-[#999] mb-1">Destinations (dst)</label>
<DestinationSelector
destinations={rule.dst}
onChange={handleDestinationsChange}
/>
</div>
</div>
);
};
export default RuleEditor;

View File

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { SourceEntry, SourceType } from './types';
import { PlusCircle, X, AlertCircle } from 'lucide-react';
import { isValidSourceValue } from './validation';
interface SourceSelectorProps {
sources: SourceEntry[];
onChange: (sources: SourceEntry[]) => void;
}
const SourceSelector: React.FC<SourceSelectorProps> = ({ sources, onChange }) => {
const [newSourceType, setNewSourceType] = useState<SourceType>('any');
const [newSourceValue, setNewSourceValue] = useState<string>('*');
const [validationError, setValidationError] = useState<string | null>(null);
const sourceTypes: { value: SourceType; label: string }[] = [
{ value: 'any', label: 'Any (*)' },
{ value: 'user', label: 'User' },
{ value: 'group', label: 'Group' },
{ value: 'ip', label: 'Tailscale IP' },
{ value: 'subnet', label: 'Subnet CIDR' },
{ value: 'host', label: 'Host' },
{ value: 'tag', label: 'Tag' },
{ value: 'autogroup', label: 'Autogroup' }
];
const validateAndAddSource = () => {
// Clear previous error
setValidationError(null);
if (newSourceType === 'any') {
onChange([...sources, { type: 'any', value: '*' }]);
return;
}
if (!newSourceValue.trim()) {
setValidationError('Value cannot be empty');
return;
}
// Validate based on source type
if (!isValidSourceValue(newSourceType, newSourceValue)) {
setValidationError(`Invalid ${newSourceType} format`);
return;
}
// If validation passes, add the source
onChange([...sources, { type: newSourceType, value: newSourceValue.trim() }]);
setNewSourceValue('');
};
const removeSource = (index: number) => {
onChange(sources.filter((_, i) => i !== index));
};
const handleSourceTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const type = e.target.value as SourceType;
setNewSourceType(type);
setValidationError(null);
// Set default value based on type
if (type === 'any') {
setNewSourceValue('*');
} else if (type === 'group') {
setNewSourceValue('group:');
} else if (type === 'tag') {
setNewSourceValue('tag:');
} else if (type === 'autogroup') {
setNewSourceValue('autogroup:');
} else {
setNewSourceValue('');
}
};
const getSourceDisplay = (source: SourceEntry): string => {
if (source.type === 'any') return '*';
if (source.type === 'group') return source.value;
if (source.type === 'tag') return source.value;
if (source.type === 'autogroup') return source.value;
return source.value;
};
return (
<div>
<div className="flex flex-wrap gap-2 mb-2">
{sources.map((source, index) => (
<div
key={index}
className="flex items-center bg-[#2a4562] text-white px-3 py-1 rounded-full"
>
<span className="mr-1">{getSourceDisplay(source)}</span>
<button
onClick={() => removeSource(index)}
className="text-[#ccc] hover:text-white"
>
<X size={14} />
</button>
</div>
))}
{sources.length === 0 && (
<div className="text-[#999] italic">No sources added (required)</div>
)}
</div>
{validationError && (
<div className="flex items-center text-red-500 text-sm mb-2">
<AlertCircle size={14} className="mr-1" />
{validationError}
</div>
)}
<div className="flex gap-2">
<select
className="p-2 border border-[#444] bg-[#333] text-white rounded-md"
value={newSourceType}
onChange={handleSourceTypeChange}
>
{sourceTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
{newSourceType !== 'any' && (
<input
type="text"
className={`flex-1 p-2 border ${validationError ? 'border-red-500' : 'border-[#444]'} bg-[#333] text-white rounded-md`}
value={newSourceValue}
onChange={(e) => {
setNewSourceValue(e.target.value);
setValidationError(null);
}}
placeholder={`Enter ${newSourceType} value`}
/>
)}
<button
onClick={validateAndAddSource}
className="bg-[#4f8cc9] hover:bg-[#5f9cd9] text-white px-3 py-2 rounded-md flex items-center"
>
<PlusCircle size={18} />
</button>
</div>
<div className="text-xs text-[#999] mt-1">
{newSourceType === 'user' && "Example: user@example.com"}
{newSourceType === 'group' && "Example: group:engineering"}
{newSourceType === 'ip' && "Example: 100.101.102.103"}
{newSourceType === 'subnet' && "Example: 192.168.1.0/24"}
{newSourceType === 'host' && "Example: my-host"}
{newSourceType === 'tag' && "Example: tag:production"}
{newSourceType === 'autogroup' && "Example: autogroup:member"}
</div>
</div>
);
};
export default SourceSelector;

View File

@ -0,0 +1,67 @@
// Type model for Tailscale / Headscale ACL policies.
// Forked from the tailscale-acl-editor repo and extended to cover the
// full headscale policy structure: groups, hosts, tagOwners, ssh, autoApprovers.
export type ACLAction = 'accept';
export type SourceType =
| 'any'
| 'user'
| 'group'
| 'ip'
| 'subnet'
| 'host'
| 'tag'
| 'autogroup';
export type DestinationType = SourceType;
export type Protocol =
| 'tcp'
| 'udp'
| 'icmp'
| 'sctp'
| 'igmp'
| 'esp'
| 'ah'
| 'gre'
| 'ipip'
| 'dccp'
| 'all';
export type PortDefinition = '*' | number | string;
export interface SourceEntry {
type: SourceType;
value: string;
}
export interface DestinationEntry {
type: DestinationType;
value: string;
ports: PortDefinition;
}
export interface ACLRule {
action: ACLAction;
src: SourceEntry[];
proto?: Protocol;
dst: DestinationEntry[];
}
/**
* Headscale extends Tailscale's policy with the same shape but accepts
* JSONC (JSON with comments). We model the subset the visual editor
* touches; unknown top-level fields are preserved verbatim through the
* `_passthrough` slot so editing rules never drops other settings.
*/
export interface ACLPolicy {
acls: ACLRule[];
groups?: Record<string, string[]>;
hosts?: Record<string, string>;
tagOwners?: Record<string, string[]>;
ssh?: unknown[];
autoApprovers?: Record<string, unknown>;
/** Anything else in the source JSON that we don't model yet. */
_passthrough?: Record<string, unknown>;
}

View File

@ -0,0 +1,157 @@
/**
* Utility functions for validating IP addresses and CIDR notation
*/
import { Address4, Address6 } from 'ip-address';
import isCidr from 'is-cidr';
/**
* Validates an IPv4 address
* @param ip The IP address to validate
* @returns True if the IP is valid, false otherwise
*/
export const isValidIPv4 = (ip: string): boolean => {
try {
return new Address4(ip).isValid();
} catch (e) {
return false;
}
};
/**
* Validates an IPv6 address
* @param ip The IP address to validate
* @returns True if the IP is valid, false otherwise
*/
export const isValidIPv6 = (ip: string): boolean => {
try {
return new Address6(ip).isValid();
} catch (e) {
return false;
}
};
/**
* Validates an IP address (IPv4 or IPv6)
* @param ip The IP address to validate
* @returns True if the IP is valid, false otherwise
*/
export const isValidIP = (ip: string): boolean => {
return isValidIPv4(ip) || isValidIPv6(ip);
};
/**
* Validates a CIDR notation
* @param cidr The CIDR notation to validate
* @returns True if the CIDR is valid, false otherwise
*/
export const isValidCIDR = (cidr: string): boolean => {
return isCidr(cidr) !== 0;
};
/**
* Validates a port or port range
* @param port The port or port range to validate
* @returns True if the port is valid, false otherwise
*/
export const isValidPort = (port: string): boolean => {
// If port is wildcard, it's valid
if (port === '*') return true;
// Check for a single port
if (/^\d+$/.test(port)) {
const portNum = parseInt(port, 10);
return portNum >= 1 && portNum <= 65535;
}
// Check for a port range (e.g., 1000-2000)
if (/^\d+-\d+$/.test(port)) {
const [startStr, endStr] = port.split('-');
const start = parseInt(startStr, 10);
const end = parseInt(endStr, 10);
return (
start >= 1 &&
start <= 65535 &&
end >= 1 &&
end <= 65535 &&
start <= end
);
}
// Check for multiple ports (e.g., 80,443)
if (/^\d+(,\d+)*$/.test(port)) {
const ports = port.split(',');
return ports.every(p => {
const portNum = parseInt(p, 10);
return portNum >= 1 && portNum <= 65535;
});
}
return false;
};
/**
* Validates an email address
* @param email The email address to validate
* @returns True if the email is valid, false otherwise
*/
export const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* Validates a hostname
* @param hostname The hostname to validate
* @returns True if the hostname is valid, false otherwise
*/
export const isValidHostname = (hostname: string): boolean => {
// Simplified hostname validation
const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$/;
return hostnameRegex.test(hostname);
};
/**
* Validates a source value based on its type
* @param type The source type
* @param value The source value
* @returns True if the value is valid for the given type, false otherwise
*/
export const isValidSourceValue = (type: string, value: string): boolean => {
if (type === 'any' && value === '*') return true;
if (type === 'user') return isValidEmail(value);
if (type === 'group') return value.startsWith('group:') && value.length > 6;
if (type === 'ip') return isValidIP(value);
if (type === 'subnet') return isValidCIDR(value);
if (type === 'host') return isValidHostname(value);
if (type === 'tag') return value.startsWith('tag:') && value.length > 4;
if (type === 'autogroup') return value.startsWith('autogroup:') && value.length > 10;
return false;
};
/**
* Validates a destination value based on its type
* @param type The destination type
* @param value The destination value
* @param port The port value
* @returns True if the value and port are valid for the given type, false otherwise
*/
export const isValidDestinationValue = (
type: string,
value: string,
port: string
): boolean => {
if (!isValidPort(port)) return false;
if (type === 'any' && value === '*') return true;
if (type === 'user') return isValidEmail(value);
if (type === 'group') return value.startsWith('group:') && value.length > 6;
if (type === 'ip') return isValidIP(value);
if (type === 'subnet') return isValidCIDR(value);
if (type === 'host') return isValidHostname(value);
if (type === 'tag') return value.startsWith('tag:') && value.length > 4;
if (type === 'autogroup') return value.startsWith('autogroup:') && value.length > 10;
return false;
};

View File

@ -1,89 +1,48 @@
--- ---
// 🛡️ Heady ACL Management
//
// Thin Astro shell — the actual editing UX is a React island.
// Server-renders the initial policy + permission flags, then hands off to
// <ACLEditor> for live editing. The island PATCHes /api/acls on save.
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
// Heady ACL Management - Alpine.js/Astro ACL Editor 🤠 import ACLEditor from '../components/acls/ACLEditor';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
import Layout from '../layouts/Layout.astro'; import Layout from '../layouts/Layout.astro';
// In a real implementation, this would come from Headscale API // In a real deployment this would come from Headscale's /v1/policy endpoint.
// For now, we'll use sample ACL data that matches Tailscale format // For now, ship a sensible example that demonstrates every modeled key.
const sampleAclPolicy = `{ const samplePolicy = {
// ACL Policy for Heady Network groups: {
// Based on Tailscale ACL format: https://tailscale.com/kb/1018/acls 'group:admin': ['admin@example.com', 'it@example.com'],
'group:dev': ['dev@example.com'],
"groups": { },
"group:admin": ["admin@company.com", "it@company.com"], hosts: {
"group:dev": ["dev@company.com", "engineers@company.com"], 'db-servers': '100.64.0.10/32',
"group:prod": ["prod@company.com"], 'web-servers': '100.64.0.20/32',
"group:sales": ["sales@company.com"] },
}, acls: [
{
"hosts": { action: 'accept',
"db-servers": "100.64.0.10/32", src: [{ type: 'group', value: 'group:admin' }],
"web-servers": "100.64.0.20-100.64.0.29", dst: [{ type: 'any', value: '*', ports: '*' }],
"dev-boxes": "100.64.1.0/24" },
}, {
action: 'accept',
"acls": [ src: [{ type: 'group', value: 'group:dev' }],
// Admins can access everything dst: [
{ { type: 'host', value: 'web-servers', ports: '80,443' },
"action": "accept", { type: 'host', value: 'db-servers', ports: '5432' },
"src": ["group:admin"], ],
"dst": ["*:*"] },
}, ],
// Developers can access dev environment
{
"action": "accept",
"src": ["group:dev"],
"dst": ["dev-boxes:22,80,443,3000-9999"]
},
// Production team restricted access
{
"action": "accept",
"src": ["group:prod"],
"dst": ["web-servers:80,443", "db-servers:5432,3306"]
},
// Sales team can only access web interfaces
{
"action": "accept",
"src": ["group:sales"],
"dst": ["web-servers:80,443"]
},
// Allow SSH between dev machines
{
"action": "accept",
"src": ["dev-boxes"],
"dst": ["dev-boxes:22"]
}
],
"ssh": [
{
"action": "accept",
"src": ["group:admin"],
"dst": ["autogroup:self"],
"users": ["root", "ubuntu"]
},
{
"action": "accept",
"src": ["group:dev"],
"dst": ["dev-boxes"],
"users": ["ubuntu", "developer"]
}
]
}`;
// Mock data for permissions and writability
const aclData = {
access: true, // User has permission to edit ACLs
writable: true, // ACL policy is in database mode (not file mode)
policy: sampleAclPolicy,
updatedAt: new Date().toISOString(),
}; };
// Get recent activity for ACL changes // Decide if the current request can edit. Wired through the React island so
// the button states match server policy.
const canEdit = true;
// Recent activity sidebar.
const recentActivity = await getCollection( const recentActivity = await getCollection(
'activity', 'activity',
({ data }) => data.resource_type === 'acl', ({ data }) => data.resource_type === 'acl',
@ -99,585 +58,97 @@ const recentActivity = await getCollection(
--- ---
<Layout title="Access Control List (ACL)"> <Layout title="Access Control List (ACL)">
<div class="min-h-screen bg-gray-900" x-data="aclPage()" x-init="init()"> <AuthenticatedLayout title="Access Control List (ACL)" requiredRole="network_admin">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="min-h-screen bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8"> <header class="mb-8">
<h1 class="text-3xl font-bold text-white flex items-center mb-4"> <h1 class="text-3xl font-bold text-white flex items-center mb-3">
<span class="mr-3">🛡️</span> <span class="mr-3">🛡️</span>
Access Control List (ACL) Access Control List
</h1> </h1>
<p class="text-gray-300 max-w-3xl">
<div class="max-w-4xl"> Visual editor for the ACL policy that controls which devices and users
<p class="text-gray-300 mb-4"> can reach which hosts. Edits are sent to Headscale via
The ACL file defines access control rules for your network. Configure groups, hosts, and rules to control which users and machines can communicate with each other. <code class="rounded bg-gray-800 px-1 text-sm">/api/acls</code>.
</p> </p>
<div class="mt-4 flex flex-wrap gap-4 text-sm">
<div class="flex flex-wrap gap-4 text-sm"> <a
<a href="https://tailscale.com/kb/1018/acls"
href="https://tailscale.com/kb/1018/acls"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 underline" class="text-blue-400 hover:text-blue-300 underline"
> >📖 Tailscale ACL Guide</a>
📖 Tailscale ACL Guide <a
</a> href="https://headscale.net/stable/ref/acls/"
<a
href="https://headscale.net/stable/ref/acls/"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 underline" class="text-blue-400 hover:text-blue-300 underline"
> >📚 Headscale ACL Reference</a>
📚 Headscale ACL Documentation
</a>
</div> </div>
</div> </header>
</div>
<!-- Permission Notices --> <div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<template x-if="!access">
<div class="mb-6 p-4 bg-yellow-900/50 border border-yellow-700 rounded-lg">
<div class="flex items-center">
<span class="text-yellow-500 text-xl mr-3">⚠️</span>
<div>
<h3 class="text-yellow-200 font-semibold">ACL Policy Restricted</h3>
<p class="text-yellow-300 text-sm mt-1">
You do not have the necessary permissions to edit the Access Control List policy.
Please contact your administrator to request access.
</p>
</div>
</div>
</div>
</template>
<template x-if="access && !writable"> <!-- Main editor (React island) -->
<div class="mb-6 p-4 bg-red-900/50 border border-red-700 rounded-lg"> <div class="lg:col-span-3">
<div class="flex items-center"> <div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<span class="text-red-500 text-xl mr-3">🚫</span> <ACLEditor
<div> client:load
<h3 class="text-red-200 font-semibold">Read-only ACL Policy</h3> initialPolicy={samplePolicy as any}
<p class="text-red-300 text-sm mt-1"> apiUrl="/api/acls"
The ACL policy mode is set to <code class="bg-red-800 px-1 rounded">file</code> in your Headscale configuration. canEdit={canEdit}
To enable editing, set <code class="bg-red-800 px-1 rounded">policy.mode</code> to />
<code class="bg-red-800 px-1 rounded">database</code> in your configuration.
</p>
</div>
</div>
</div>
</template>
<!-- Error Display -->
<template x-if="error">
<div class="mb-6 p-4 bg-red-900/50 border border-red-700 rounded-lg">
<div class="flex items-center">
<span class="text-red-500 text-xl mr-3">❌</span>
<div>
<h3 class="text-red-200 font-semibold" x-text="error.split(':')[0] || 'Error'"></h3>
<p class="text-red-300 text-sm mt-1" x-text="error.split(':').slice(1).join(': ') || 'An unknown error occurred'"></p>
</div>
</div>
</div>
</template>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<!-- Main Editor Area -->
<div class="lg:col-span-3">
<div class="bg-gray-800 rounded-lg border border-gray-700">
<!-- Tab Navigation -->
<div class="border-b border-gray-700">
<nav class="flex space-x-8 px-6" aria-label="ACL Editor Tabs">
<button
@click="activeTab = 'edit'"
:class="activeTab === 'edit' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 transition-colors"
>
<span>✏️</span>
<span>Edit File</span>
</button>
<button
@click="activeTab = 'diff'"
:class="activeTab === 'diff' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 transition-colors"
>
<span>👀</span>
<span>Preview Changes</span>
<template x-if="hasChanges">
<span class="bg-yellow-500 text-black text-xs px-2 py-0.5 rounded-full">!</span>
</template>
</button>
<button
@click="activeTab = 'preview'"
:class="activeTab === 'preview' ? 'border-blue-500 text-blue-400' : 'border-transparent text-gray-400 hover:text-gray-300'"
class="py-4 px-1 border-b-2 font-medium text-sm flex items-center space-x-2 transition-colors"
>
<span>🧪</span>
<span>Preview Rules</span>
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Edit Tab -->
<div x-show="activeTab === 'edit'" class="space-y-4">
<div class="bg-gray-900 rounded-lg border border-gray-600">
<div class="border-b border-gray-600 px-4 py-2 flex items-center justify-between">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-400">ACL Policy Editor</span>
<template x-if="!disabled">
<span class="text-xs text-green-400">● Ready</span>
</template>
<template x-if="disabled">
<span class="text-xs text-red-400">● Read-only</span>
</template>
</div>
<div class="flex items-center space-x-2 text-xs text-gray-500">
<span x-text="`${editedPolicy.split('\\n').length} lines`"></span>
<span x-text="`${editedPolicy.length} chars`"></span>
</div>
</div>
<textarea
x-model="editedPolicy"
:disabled="disabled"
class="w-full h-96 bg-gray-900 text-gray-100 font-mono text-sm p-4 border-0 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-inset"
placeholder="Enter your ACL policy in JSON format..."
spellcheck="false"
style="tab-size: 2;"
></textarea>
</div>
<!-- Syntax Help -->
<div class="text-xs text-gray-500 flex flex-wrap gap-4">
<span>💡 Tip: Use JSON with comments (JSONC format)</span>
<span>📝 Ctrl+/ to toggle line comments</span>
<span>🔍 Ctrl+F to search</span>
</div>
</div>
<!-- Diff Tab -->
<div x-show="activeTab === 'diff'">
<template x-if="!hasChanges">
<div class="flex flex-col items-center py-16 text-gray-400">
<span class="text-4xl mb-4">📄</span>
<h3 class="text-lg font-semibold mb-2">No Changes</h3>
<p class="text-sm">Your policy matches the saved version.</p>
</div>
</template>
<template x-if="hasChanges">
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-white">Policy Changes</h3>
<div class="text-sm text-gray-400">
<span class="text-red-400">- Removed</span>
<span class="ml-4 text-green-400">+ Added</span>
</div>
</div>
<!-- Simple diff display -->
<div class="bg-gray-900 rounded-lg border border-gray-600 overflow-hidden">
<div class="max-h-96 overflow-y-auto">
<div class="grid grid-cols-2 gap-px bg-gray-600">
<!-- Original -->
<div class="bg-gray-900">
<div class="bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 border-b border-gray-600">
Original Policy
</div>
<pre class="text-xs text-gray-300 p-4 whitespace-pre-wrap" x-text="originalPolicy"></pre>
</div>
<!-- Modified -->
<div class="bg-gray-900">
<div class="bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 border-b border-gray-600">
Modified Policy
</div>
<pre class="text-xs text-gray-300 p-4 whitespace-pre-wrap" x-text="editedPolicy"></pre>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Preview Tab -->
<div x-show="activeTab === 'preview'">
<div class="flex flex-col items-center py-16 text-gray-400">
<span class="text-4xl mb-4">🚧</span>
<h3 class="text-lg font-semibold mb-2">Preview Rules</h3>
<p class="text-sm text-center max-w-md">
Rule previewing is not available yet. This feature is complex to implement
and requires parsing the full ACL policy structure.
</p>
<div class="mt-4 text-xs text-blue-400">
Coming soon in a future update! 🚀
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Sidebar -->
<div class="mt-6 flex space-x-3"> <aside class="space-y-6">
<button
@click="savePolicy()"
:disabled="disabled || saving || !hasChanges || editedPolicy.length === 0"
:class="disabled || saving || !hasChanges || editedPolicy.length === 0
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'"
class="px-6 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<template x-if="saving">
<span class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
</template>
<template x-if="!saving">
<span>💾</span>
</template>
<span x-text="saving ? 'Saving...' : 'Save Policy'"></span>
</button>
<button
@click="discardChanges()"
:disabled="disabled || saving || !hasChanges"
:class="disabled || saving || !hasChanges
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: 'bg-gray-700 hover:bg-gray-600 text-white'"
class="px-6 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<span>🗑️</span>
<span>Discard Changes</span>
</button>
<button
@click="formatPolicy()"
:disabled="disabled || saving"
:class="disabled || saving
? 'bg-gray-600 text-gray-400 cursor-not-allowed'
: 'bg-gray-700 hover:bg-gray-600 text-white'"
class="px-6 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<span>✨</span>
<span>Format JSON</span>
</button>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Policy Stats -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">📊</span>
Policy Statistics
</h3>
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Groups:</span>
<span class="text-white font-mono" x-text="policyStats.groups"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Hosts:</span>
<span class="text-white font-mono" x-text="policyStats.hosts"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">ACL Rules:</span>
<span class="text-white font-mono" x-text="policyStats.acls"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">SSH Rules:</span>
<span class="text-white font-mono" x-text="policyStats.ssh"></span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">File Size:</span>
<span class="text-white font-mono" x-text="formatBytes(editedPolicy.length)"></span>
</div>
</div>
</div>
<!-- Quick Templates -->
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">📋</span>
Quick Templates
</h3>
<div class="space-y-2">
<button
@click="loadTemplate('basic')"
class="w-full bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm text-left transition-colors"
>
🏠 Basic Home Network
</button>
<button
@click="loadTemplate('enterprise')"
class="w-full bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm text-left transition-colors"
>
🏢 Enterprise Setup
</button>
<button
@click="loadTemplate('dev')"
class="w-full bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm text-left transition-colors"
>
💻 Developer Environment
</button>
<button
@click="loadTemplate('empty')"
class="w-full bg-gray-700 hover:bg-gray-600 text-white px-3 py-2 rounded text-sm text-left transition-colors"
>
📄 Empty Policy
</button>
</div>
</div>
<!-- Recent Changes -->
{recentActivity.length > 0 && (
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6"> <div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center"> <h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🕐</span> <span class="mr-2">📊</span>
Recent Changes Policy Summary
</h3> </h3>
<div class="space-y-3"> <dl class="space-y-2 text-sm">
{recentActivity.map(activity => ( <div class="flex justify-between">
<div class="flex items-start space-x-3 text-sm"> <dt class="text-gray-400">Groups</dt>
<div class="w-2 h-2 bg-blue-500 rounded-full mt-2 flex-shrink-0"></div> <dd class="text-white font-mono">{Object.keys(samplePolicy.groups ?? {}).length}</dd>
<div> </div>
<div class="text-white font-medium">{activity.data.action}</div> <div class="flex justify-between">
<div class="text-gray-400 text-xs"> <dt class="text-gray-400">Hosts</dt>
{activity.data.user_email} • {new Date(activity.data.timestamp).toLocaleString()} <dd class="text-white font-mono">{Object.keys(samplePolicy.hosts ?? {}).length}</dd>
</div> </div>
</div> <div class="flex justify-between">
</div> <dt class="text-gray-400">ACL rules</dt>
))} <dd class="text-white font-mono">{samplePolicy.acls.length}</dd>
</div> </div>
</dl>
</div> </div>
)}
{recentActivity.length > 0 && (
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
<span class="mr-2">🕐</span>
Recent Changes
</h3>
<ul class="space-y-3">
{recentActivity.map((activity) => (
<li class="flex items-start space-x-3 text-sm">
<div class="w-2 h-2 bg-blue-500 rounded-full mt-2 shrink-0"></div>
<div>
<div class="text-white font-medium">{activity.data.action}</div>
<div class="text-gray-400 text-xs">
{activity.data.user_email} • {new Date(activity.data.timestamp).toLocaleString()}
</div>
</div>
</li>
))}
</ul>
</div>
)}
</aside>
</div> </div>
</div> </div>
</div> </div>
</div> </AuthenticatedLayout>
</Layout>
<script>
function aclPage() {
return {
// Initialize with server data
access: true,
writable: true,
originalPolicy: JSON.stringify(aclData.policy, null, 2),
editedPolicy: JSON.stringify(aclData.policy, null, 2),
// UI state
activeTab: 'edit',
saving: false,
error: null,
// Computed properties
get disabled() {
return !this.access || !this.writable;
},
get hasChanges() {
return this.editedPolicy !== this.originalPolicy;
},
get policyStats() {
try {
const policy = JSON.parse(this.editedPolicy);
return {
groups: Object.keys(policy.groups || {}).length,
hosts: Object.keys(policy.hosts || {}).length,
acls: (policy.acls || []).length,
ssh: (policy.ssh || []).length
};
} catch (e) {
return { groups: 0, hosts: 0, acls: 0, ssh: 0 };
}
},
init() {
// Listen for keyboard shortcuts
this.$el.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 's':
e.preventDefault();
if (!this.disabled && this.hasChanges) {
this.savePolicy();
}
break;
case 'z':
if (!e.shiftKey) {
e.preventDefault();
this.discardChanges();
}
break;
}
}
});
},
async savePolicy() {
if (this.disabled || this.saving || !this.hasChanges) return;
this.saving = true;
this.error = null;
try {
// Validate JSON first
JSON.parse(this.editedPolicy);
const formData = new FormData();
formData.append('policy', this.editedPolicy);
const response = await fetch('/api/acls', {
method: 'PATCH',
body: formData,
});
const result = await response.json();
if (result.success) {
this.originalPolicy = this.editedPolicy;
this.showToast('Policy updated successfully!', 'success');
// Dispatch refresh event
this.$dispatch('data-refresh');
} else {
this.error = result.error || 'Failed to update policy';
this.showToast('Failed to update policy', 'error');
}
} catch (e) {
if (e instanceof SyntaxError) {
this.error = 'Invalid JSON syntax: ' + e.message;
} else {
this.error = 'Failed to save policy: ' + e.message;
}
this.showToast('Invalid JSON format', 'error');
} finally {
this.saving = false;
}
},
discardChanges() {
if (this.disabled || !this.hasChanges) return;
this.editedPolicy = this.originalPolicy;
this.error = null;
this.showToast('Changes discarded', 'info');
},
formatPolicy() {
if (this.disabled) return;
try {
const parsed = JSON.parse(this.editedPolicy);
this.editedPolicy = JSON.stringify(parsed, null, 2);
this.showToast('Policy formatted', 'success');
} catch (e) {
this.showToast('Invalid JSON - cannot format', 'error');
}
},
loadTemplate(type) {
const templates = {
basic: {
groups: {
"group:family": ["parent@example.com", "child@example.com"]
},
acls: [
{
action: "accept",
src: ["group:family"],
dst: ["*:*"]
}
]
},
enterprise: {
groups: {
"group:admin": ["admin@company.com"],
"group:engineering": ["dev@company.com"],
"group:sales": ["sales@company.com"]
},
hosts: {
"production": "10.0.1.0/24",
"development": "10.0.2.0/24"
},
acls: [
{
action: "accept",
src: ["group:admin"],
dst: ["*:*"]
},
{
action: "accept",
src: ["group:engineering"],
dst: ["development:*"]
},
{
action: "accept",
src: ["group:sales"],
dst: ["production:80,443"]
}
]
},
dev: {
groups: {
"group:developers": ["dev@company.com"]
},
hosts: {
"dev-servers": "192.168.1.0/24"
},
acls: [
{
action: "accept",
src: ["group:developers"],
dst: ["dev-servers:22,80,443,3000-9000"]
}
],
ssh: [
{
action: "accept",
src: ["group:developers"],
dst: ["dev-servers"],
users: ["ubuntu", "developer"]
}
]
},
empty: {}
};
this.editedPolicy = JSON.stringify(templates[type], null, 2);
this.showToast(`Loaded ${type} template`, 'info');
},
formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
},
showToast(message, type = 'info') {
// Use the global toast system from Layout
if (window.headyApp) {
window.headyApp().showToast(message, type);
} else {
console.log(`${type.toUpperCase()}: ${message}`);
}
}
};
}
// Make globally available
window.aclPage = aclPage;
</script>
</Layout>

View File

@ -705,7 +705,7 @@ const recentActivity = await getCollection('activity', ({ data }) =>
</div> </div>
</div> </div>
<script> <script is:inline define:vars={{ settingsData, authKeysData, permissions }}>
function settingsPage() { function settingsPage() {
return { return {
// Initialize with server data // Initialize with server data