diff --git a/astro.config.mjs b/astro.config.mjs index 5120484..cc11b34 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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 diff --git a/package.json b/package.json index 4c6be00..5add468 100644 --- a/package.json +++ b/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2535617..5f540d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/components/acls/ACLEditor.tsx b/src/components/acls/ACLEditor.tsx new file mode 100644 index 0000000..4a19d8e --- /dev/null +++ b/src/components/acls/ACLEditor.tsx @@ -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): ACLPolicy { + const known = new Set([ + 'acls', + 'groups', + 'hosts', + 'tagOwners', + 'ssh', + 'autoApprovers', + ]); + const passthrough: Record = {}; + 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 | undefined, + hosts: raw.hosts as Record | undefined, + tagOwners: raw.tagOwners as Record | undefined, + ssh: Array.isArray(raw.ssh) ? (raw.ssh as unknown[]) : undefined, + autoApprovers: raw.autoApprovers as + | Record + | 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 { + const out: Record = { ...(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 = ({ + initialPolicy, + apiUrl = '/api/acls', + canEdit = true, +}) => { + const [policy, setPolicy] = useState(initialPolicy); + const [originalJson] = useState(() => + JSON.stringify(joinPolicy(initialPolicy), null, 2), + ); + const [importError, setImportError] = useState(null); + const [saveState, setSaveState] = useState< + 'idle' | 'saving' | 'saved' | 'error' + >('idle'); + const [saveError, setSaveError] = useState(null); + const fileInputRef = useRef(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, + ) => { + 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)); + 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 ( +
+ {/* Action bar */} +
+
+ {policy.acls.length} rule{policy.acls.length === 1 ? '' : 's'} + {hasChanges && ( + + unsaved changes + + )} +
+
+ + + + + +
+
+ + {/* Inline error banners */} + {(importError || saveError) && ( +
+ +
{importError || saveError}
+ +
+ )} + + {/* Rule list */} +
+ {policy.acls.length === 0 ? ( +
+

No rules yet.

+ +
+ ) : ( + policy.acls.map((rule, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rules don't have stable IDs + updateRule(index, updated)} + onRemove={() => removeRule(index)} + ruleNumber={index + 1} + /> + )) + )} +
+ + {/* JSON preview */} +
+ + Policy JSON Preview + +
+					{previewJson}
+				
+
+
+ ); +}; + +export default ACLEditor; diff --git a/src/components/acls/DestinationSelector.tsx b/src/components/acls/DestinationSelector.tsx new file mode 100644 index 0000000..da99c7b --- /dev/null +++ b/src/components/acls/DestinationSelector.tsx @@ -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 = ({ destinations, onChange }) => { + const [newDestType, setNewDestType] = useState('any'); + const [newDestValue, setNewDestValue] = useState('*'); + const [newDestPorts, setNewDestPorts] = useState('*'); + const [validationError, setValidationError] = useState(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) => { + 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 ( +
+
+ {destinations.map((dest, index) => ( +
+ {getDestinationDisplay(dest)} + +
+ ))} + {destinations.length === 0 && ( +
No destinations added (required)
+ )} +
+ + {validationError && ( +
+ + {validationError} +
+ )} + +
+ + + {newDestType !== 'any' && ( + <> + { + setNewDestValue(e.target.value); + setValidationError(null); + }} + placeholder={`Enter ${newDestType} value`} + /> + + { + setNewDestPorts(e.target.value); + setValidationError(null); + }} + placeholder="Ports" + /> + + )} + + +
+ +
+ {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)"} +
+
+ ); +}; + +export default DestinationSelector; diff --git a/src/components/acls/RuleEditor.tsx b/src/components/acls/RuleEditor.tsx new file mode 100644 index 0000000..e4eff2b --- /dev/null +++ b/src/components/acls/RuleEditor.tsx @@ -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 = ({ rule, onChange, onRemove, ruleNumber }) => { + const protocols: Protocol[] = ['tcp', 'udp', 'icmp', 'sctp', 'igmp', 'esp', 'ah', 'gre', 'ipip', 'dccp', 'all']; + + const handleProtoChange = (e: React.ChangeEvent) => { + 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 ( +
+
+

Rule #{ruleNumber}

+ +
+ +
+
+ + +

+ Tailscale only supports 'accept' actions (deny by default) +

+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ ); +}; + +export default RuleEditor; diff --git a/src/components/acls/SourceSelector.tsx b/src/components/acls/SourceSelector.tsx new file mode 100644 index 0000000..d7e0c9b --- /dev/null +++ b/src/components/acls/SourceSelector.tsx @@ -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 = ({ sources, onChange }) => { + const [newSourceType, setNewSourceType] = useState('any'); + const [newSourceValue, setNewSourceValue] = useState('*'); + const [validationError, setValidationError] = useState(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) => { + 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 ( +
+
+ {sources.map((source, index) => ( +
+ {getSourceDisplay(source)} + +
+ ))} + {sources.length === 0 && ( +
No sources added (required)
+ )} +
+ + {validationError && ( +
+ + {validationError} +
+ )} + +
+ + + {newSourceType !== 'any' && ( + { + setNewSourceValue(e.target.value); + setValidationError(null); + }} + placeholder={`Enter ${newSourceType} value`} + /> + )} + + +
+ +
+ {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"} +
+
+ ); +}; + +export default SourceSelector; diff --git a/src/components/acls/types.ts b/src/components/acls/types.ts new file mode 100644 index 0000000..232f067 --- /dev/null +++ b/src/components/acls/types.ts @@ -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; + hosts?: Record; + tagOwners?: Record; + ssh?: unknown[]; + autoApprovers?: Record; + /** Anything else in the source JSON that we don't model yet. */ + _passthrough?: Record; +} diff --git a/src/components/acls/validation.ts b/src/components/acls/validation.ts new file mode 100644 index 0000000..6559066 --- /dev/null +++ b/src/components/acls/validation.ts @@ -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; +}; diff --git a/src/pages/acls.astro b/src/pages/acls.astro index 88ef6da..9ae10f1 100644 --- a/src/pages/acls.astro +++ b/src/pages/acls.astro @@ -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 +// 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( --- -
-
- - -
-

- πŸ›‘οΈ - Access Control List (ACL) -

- -
-

- 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. + +

+
+ +
+

+ πŸ›‘οΈ + Access Control List +

+

+ Visual editor for the ACL policy that controls which devices and users + can reach which hosts. Edits are sent to Headscale via + /api/acls.

- - -
-
+ - - +
- - - - - -
- - -
-
- - -
- -
- - -
- - -
-
-
-
- ACL Policy Editor - - -
-
- - -
-
- - -
- - -
- πŸ’‘ Tip: Use JSON with comments (JSONC format) - πŸ“ Ctrl+/ to toggle line comments - πŸ” Ctrl+F to search -
-
- - -
- - - -
- - -
-
- 🚧 -

Preview Rules

-

- Rule previewing is not available yet. This feature is complex to implement - and requires parsing the full ACL policy structure. -

-
- Coming soon in a future update! πŸš€ -
-
-
- + +
+
+
- -
- - - - - -
-
- - -
- - -
-

- πŸ“Š - Policy Statistics -

-
-
- Groups: - -
-
- Hosts: - -
-
- ACL Rules: - -
-
- SSH Rules: - -
-
- File Size: - -
-
-
- - -
-

- πŸ“‹ - Quick Templates -

-
- - - - -
-
- - - {recentActivity.length > 0 && ( + +
-
- - - \ No newline at end of file + + diff --git a/src/pages/settings.astro b/src/pages/settings.astro index ea9f04a..6ed5bb1 100644 --- a/src/pages/settings.astro +++ b/src/pages/settings.astro @@ -705,7 +705,7 @@ const recentActivity = await getCollection('activity', ({ data }) =>
-