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 react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
import { defineConfig } from 'astro/config';
@ -14,6 +15,8 @@ export default defineConfig({
// Tailwind configuration
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

View File

@ -1,7 +1,7 @@
{
"name": "heady",
"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",
"private": true,
"scripts": {
@ -23,6 +23,7 @@
},
"dependencies": {
"@astrojs/node": "^8.3.4",
"@astrojs/react": "^3.6.3",
"@astrojs/tailwind": "^5.1.2",
"@kubernetes/client-node": "^1.3.0",
"@libsql/client": "0.15.12",
@ -36,10 +37,15 @@
"dotenv": "17.2.1",
"drizzle-orm": "0.44.4",
"guacamole-lite": "^1.2.0",
"ip-address": "^9.0.5",
"is-cidr": "^5.0.3",
"jose": "6.1.0",
"lucide": "^0.456.0",
"lucide-react": "^0.456.0",
"mime": "^4.0.7",
"openid-client": "6.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "3.3.1",
"tailwindcss": "^3.4.14",
"ulidx": "2.4.1",
@ -52,6 +58,8 @@
"@biomejs/biome": "^2.2.0",
"@tailwindcss/vite": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^2.1.8",
"drizzle-kit": "^0.31.4",

207
pnpm-lock.yaml generated
View File

@ -14,6 +14,9 @@ importers:
'@astrojs/node':
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))
'@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':
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)
@ -53,18 +56,33 @@ importers:
guacamole-lite:
specifier: ^1.2.0
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:
specifier: 6.1.0
version: 6.1.0
lucide:
specifier: ^0.456.0
version: 0.456.0
lucide-react:
specifier: ^0.456.0
version: 0.456.0(react@18.3.1)
mime:
specifier: ^1.6.0
version: 1.6.0
openid-client:
specifier: 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:
specifier: 3.3.1
version: 3.3.1
@ -96,6 +114,12 @@ importers:
'@types/node':
specifier: ^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':
specifier: ^8.18.1
version: 8.18.1
@ -185,6 +209,15 @@ packages:
resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==}
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':
resolution: {integrity: sha512-1diguZEau7FZ9vIjzE4BwavGdhD3+JkdS8zmibl1ene+EHgIU5hI0NMgRYG3yea+Niaf7cyMwjeWeLvzq/maxg==}
peerDependencies:
@ -240,6 +273,10 @@ packages:
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
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':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
@ -272,6 +309,18 @@ packages:
peerDependencies:
'@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':
resolution: {integrity: sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==}
engines: {node: '>=6.9.0'}
@ -1154,6 +1203,9 @@ packages:
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
'@rolldown/pluginutils@1.0.0-beta.29':
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
@ -1473,6 +1525,17 @@ packages:
'@types/node@24.3.0':
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':
resolution: {integrity: sha512-azOCy05sXVXrO+qklf0c/B07H/oHaIuDDAiHPVwlk3A9Ek+ksHyTeMajLZl3r76FxpPpxem//4Te61G1iW3Giw==}
@ -1488,6 +1551,12 @@ packages:
'@ungap/structured-clone@1.3.0':
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':
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -1898,6 +1967,10 @@ packages:
resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==}
engines: {node: '>=8'}
cidr-regex@4.1.3:
resolution: {integrity: sha512-86M1y3ZeQvpZkZejQCcS+IaSWjlDUC+ORP0peScQ4uEUFCZ8bEQVz7NlJHqysoUb6w3zCjx4Mq/8/2RHhMwHYw==}
engines: {node: '>=14'}
cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
@ -1972,6 +2045,9 @@ packages:
csstype@3.1.3:
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:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@ -2537,6 +2613,10 @@ packages:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
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:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
@ -2544,6 +2624,10 @@ packages:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-cidr@5.1.1:
resolution: {integrity: sha512-AwzRMjtJNTPOgm7xuYZ71715z99t+4yRnSnSzgK5err5+heYi4zMuvmpUadaJ28+KCXCQo8CjUrKQZRWSPmqTQ==}
engines: {node: '>=14'}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@ -2850,6 +2934,10 @@ packages:
longest-streak@3.1.0:
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:
resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==}
@ -2860,6 +2948,11 @@ packages:
lru-cache@5.1.1:
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:
resolution: {integrity: sha512-/QHD4F0KXx7YECX9bhaac5TMPtR6vSv6qxHLtL61Z6Xp6IdUUsEF1zo6McEoGJw1z9msAx8QiQn6u3g704mGlg==}
@ -3357,6 +3450,19 @@ packages:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
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:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
@ -3518,6 +3624,9 @@ packages:
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
section-matter@1.0.0:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
@ -3838,6 +3947,9 @@ packages:
resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==}
engines: {node: '>=16'}
ultrahtml@1.6.0:
resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@ -4357,6 +4469,26 @@ snapshots:
dependencies:
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)':
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)
@ -4451,6 +4583,8 @@ snapshots:
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-plugin-utils@7.29.7': {}
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
@ -4475,6 +4609,16 @@ snapshots:
'@babel/core': 7.28.3
'@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)':
dependencies:
'@babel/core': 7.28.3
@ -5118,6 +5262,8 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.0.0-beta.33':
optional: true
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rolldown/pluginutils@1.0.0-beta.29': {}
'@rolldown/pluginutils@1.0.0-beta.33': {}
@ -5422,6 +5568,17 @@ snapshots:
dependencies:
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':
dependencies:
'@types/node': 24.3.0
@ -5436,6 +5593,18 @@ snapshots:
'@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))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
@ -5958,6 +6127,10 @@ snapshots:
ci-info@4.3.0: {}
cidr-regex@4.1.3:
dependencies:
ip-regex: 5.0.0
cli-boxes@3.0.0: {}
cli-cursor@5.0.0:
@ -6023,6 +6196,8 @@ snapshots:
csstype@3.1.3: {}
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {}
date-fns@4.1.0: {}
@ -6566,6 +6741,8 @@ snapshots:
jsbn: 1.1.0
sprintf-js: 1.1.3
ip-regex@5.0.0: {}
is-arrayish@0.3.4:
optional: true
@ -6573,6 +6750,10 @@ snapshots:
dependencies:
binary-extensions: 2.3.0
is-cidr@5.1.1:
dependencies:
cidr-regex: 4.1.3
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@ -6818,6 +6999,10 @@ snapshots:
longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
loupe@3.1.4: {}
lru-cache@10.2.2: {}
@ -6826,6 +7011,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.456.0(react@18.3.1):
dependencies:
react: 18.3.1
lucide@0.456.0: {}
magic-string@0.30.17:
@ -7475,6 +7664,18 @@ snapshots:
strip-json-comments: 2.0.1
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:
dependencies:
pify: 2.3.0
@ -7701,6 +7902,10 @@ snapshots:
safe-buffer@5.2.1:
optional: true
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0
section-matter@1.0.0:
dependencies:
extend-shallow: 2.0.1
@ -8096,6 +8301,8 @@ snapshots:
dependencies:
layerr: 3.0.0
ultrahtml@1.6.0: {}
undici-types@6.21.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';
// 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';
// In a real implementation, this would come from Headscale API
// For now, we'll use sample ACL data that matches Tailscale format
const sampleAclPolicy = `{
// ACL Policy for Heady Network
// Based on Tailscale ACL format: https://tailscale.com/kb/1018/acls
"groups": {
"group:admin": ["admin@company.com", "it@company.com"],
"group:dev": ["dev@company.com", "engineers@company.com"],
"group:prod": ["prod@company.com"],
"group:sales": ["sales@company.com"]
},
"hosts": {
"db-servers": "100.64.0.10/32",
"web-servers": "100.64.0.20-100.64.0.29",
"dev-boxes": "100.64.1.0/24"
},
"acls": [
// Admins can access everything
{
"action": "accept",
"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(),
// In a real deployment this would come from Headscale's /v1/policy endpoint.
// For now, ship a sensible example that demonstrates every modeled key.
const samplePolicy = {
groups: {
'group:admin': ['admin@example.com', 'it@example.com'],
'group:dev': ['dev@example.com'],
},
hosts: {
'db-servers': '100.64.0.10/32',
'web-servers': '100.64.0.20/32',
},
acls: [
{
action: 'accept',
src: [{ type: 'group', value: 'group:admin' }],
dst: [{ type: 'any', value: '*', ports: '*' }],
},
{
action: 'accept',
src: [{ type: 'group', value: 'group:dev' }],
dst: [
{ type: 'host', value: 'web-servers', ports: '80,443' },
{ type: 'host', value: 'db-servers', ports: '5432' },
],
},
],
};
// 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(
'activity',
({ data }) => data.resource_type === 'acl',
@ -99,585 +58,97 @@ const recentActivity = await getCollection(
---
<Layout title="Access Control List (ACL)">
<div class="min-h-screen bg-gray-900" x-data="aclPage()" x-init="init()">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white flex items-center mb-4">
<span class="mr-3">🛡️</span>
Access Control List (ACL)
</h1>
<div class="max-w-4xl">
<p class="text-gray-300 mb-4">
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.
<AuthenticatedLayout title="Access Control List (ACL)" requiredRole="network_admin">
<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 class="mb-8">
<h1 class="text-3xl font-bold text-white flex items-center mb-3">
<span class="mr-3">🛡️</span>
Access Control List
</h1>
<p class="text-gray-300 max-w-3xl">
Visual editor for the ACL policy that controls which devices and users
can reach which hosts. Edits are sent to Headscale via
<code class="rounded bg-gray-800 px-1 text-sm">/api/acls</code>.
</p>
<div class="flex flex-wrap gap-4 text-sm">
<a
href="https://tailscale.com/kb/1018/acls"
<div class="mt-4 flex flex-wrap gap-4 text-sm">
<a
href="https://tailscale.com/kb/1018/acls"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 underline"
>
📖 Tailscale ACL Guide
</a>
<a
href="https://headscale.net/stable/ref/acls/"
>📖 Tailscale ACL Guide</a>
<a
href="https://headscale.net/stable/ref/acls/"
target="_blank"
rel="noopener noreferrer"
class="text-blue-400 hover:text-blue-300 underline"
>
📚 Headscale ACL Documentation
</a>
>📚 Headscale ACL Reference</a>
</div>
</div>
</div>
</header>
<!-- Permission Notices -->
<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>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<template x-if="access && !writable">
<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">Read-only ACL Policy</h3>
<p class="text-red-300 text-sm mt-1">
The ACL policy mode is set to <code class="bg-red-800 px-1 rounded">file</code> in your Headscale configuration.
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>
<!-- Main editor (React island) -->
<div class="lg:col-span-3">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<ACLEditor
client:load
initialPolicy={samplePolicy as any}
apiUrl="/api/acls"
canEdit={canEdit}
/>
</div>
</div>
<!-- Action Buttons -->
<div class="mt-6 flex space-x-3">
<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 && (
<!-- Sidebar -->
<aside class="space-y-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">
<span class="mr-2">🕐</span>
Recent Changes
<span class="mr-2">📊</span>
Policy Summary
</h3>
<div class="space-y-3">
{recentActivity.map(activity => (
<div class="flex items-start space-x-3 text-sm">
<div class="w-2 h-2 bg-blue-500 rounded-full mt-2 flex-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>
</div>
))}
</div>
<dl class="space-y-2 text-sm">
<div class="flex justify-between">
<dt class="text-gray-400">Groups</dt>
<dd class="text-white font-mono">{Object.keys(samplePolicy.groups ?? {}).length}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">Hosts</dt>
<dd class="text-white font-mono">{Object.keys(samplePolicy.hosts ?? {}).length}</dd>
</div>
<div class="flex justify-between">
<dt class="text-gray-400">ACL rules</dt>
<dd class="text-white font-mono">{samplePolicy.acls.length}</dd>
</div>
</dl>
</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>
<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>
</AuthenticatedLayout>
</Layout>

View File

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