acls: refactor editor into React components
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:
parent
21175c5b7a
commit
36c1b18724
@ -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
|
||||
|
||||
10
package.json
10
package.json
@ -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
207
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
307
src/components/acls/ACLEditor.tsx
Normal file
307
src/components/acls/ACLEditor.tsx
Normal 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;
|
||||
182
src/components/acls/DestinationSelector.tsx
Normal file
182
src/components/acls/DestinationSelector.tsx
Normal 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;
|
||||
101
src/components/acls/RuleEditor.tsx
Normal file
101
src/components/acls/RuleEditor.tsx
Normal 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;
|
||||
157
src/components/acls/SourceSelector.tsx
Normal file
157
src/components/acls/SourceSelector.tsx
Normal 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;
|
||||
67
src/components/acls/types.ts
Normal file
67
src/components/acls/types.ts
Normal 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>;
|
||||
}
|
||||
157
src/components/acls/validation.ts
Normal file
157
src/components/acls/validation.ts
Normal 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;
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user