diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 774dc59..ffe013d 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -32,10 +32,6 @@ services:
timeout: 3s
volumes:
- heady_redis_data:/data
- # Expose to localhost so the Heady dev server (running on the host) can
- # use the same Redis instance Authentik uses for its session store.
- ports:
- - '127.0.0.1:6389:6379'
networks: [authentik-internal]
heady-authentik-server:
diff --git a/package.json b/package.json
index 1398f27..5add468 100644
--- a/package.json
+++ b/package.json
@@ -27,27 +27,16 @@
"@astrojs/tailwind": "^5.1.2",
"@kubernetes/client-node": "^1.3.0",
"@libsql/client": "0.15.12",
- "@radix-ui/react-avatar": "^1.1.12",
- "@radix-ui/react-dialog": "^1.1.16",
- "@radix-ui/react-dropdown-menu": "^2.1.17",
- "@radix-ui/react-select": "^2.3.0",
- "@radix-ui/react-separator": "^1.1.9",
- "@radix-ui/react-slot": "^1.2.5",
- "@radix-ui/react-switch": "^1.3.0",
- "@radix-ui/react-tabs": "^1.1.14",
- "@radix-ui/react-tooltip": "^1.2.9",
"@shopify/lang-jsonc": "^1.0.1",
"alpinejs": "^3.14.1",
"arktype": "^2.1.20",
"astro": "^4.16.18",
"chart.js": "^4.4.7",
- "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "17.2.1",
"drizzle-orm": "0.44.4",
"guacamole-lite": "^1.2.0",
- "ioredis": "^5.11.1",
"ip-address": "^9.0.5",
"is-cidr": "^5.0.3",
"jose": "6.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 02bdc0f..5f540d0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,33 +26,6 @@ importers:
'@libsql/client':
specifier: 0.15.12
version: 0.15.12(bufferutil@4.0.9)(utf-8-validate@5.0.10)
- '@radix-ui/react-avatar':
- specifier: ^1.1.12
- version: 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-dialog':
- specifier: ^1.1.16
- version: 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-dropdown-menu':
- specifier: ^2.1.17
- version: 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-select':
- specifier: ^2.3.0
- version: 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-separator':
- specifier: ^1.1.9
- version: 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot':
- specifier: ^1.2.5
- version: 1.2.5(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-switch':
- specifier: ^1.3.0
- version: 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-tabs':
- specifier: ^1.1.14
- version: 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-tooltip':
- specifier: ^1.2.9
- version: 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@shopify/lang-jsonc':
specifier: ^1.0.1
version: 1.0.1
@@ -68,9 +41,6 @@ importers:
chart.js:
specifier: ^4.4.7
version: 4.5.0
- class-variance-authority:
- specifier: ^0.7.1
- version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -86,9 +56,6 @@ importers:
guacamole-lite:
specifier: ^1.2.0
version: 1.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
- ioredis:
- specifier: ^5.11.1
- version: 5.11.1
ip-address:
specifier: ^9.0.5
version: 9.0.5
@@ -901,21 +868,6 @@ packages:
cpu: [x64]
os: [win32]
- '@floating-ui/core@1.7.5':
- resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
-
- '@floating-ui/dom@1.7.6':
- resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
-
- '@floating-ui/react-dom@2.1.8':
- resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
- peerDependencies:
- react: '>=16.8.0'
- react-dom: '>=16.8.0'
-
- '@floating-ui/utils@0.2.11':
- resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
-
'@iconify-json/simple-icons@1.2.48':
resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==}
@@ -1027,9 +979,6 @@ packages:
cpu: [x64]
os: [win32]
- '@ioredis/commands@1.10.0':
- resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==}
-
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1184,397 +1133,6 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
- '@radix-ui/number@1.1.2':
- resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
-
- '@radix-ui/primitive@1.1.4':
- resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==}
-
- '@radix-ui/react-arrow@1.1.9':
- resolution: {integrity: sha512-yqHW5WQ/cTpU/un7dqqIKNy2iRU8BC0JB78PEzTfCCYvZu1U6W9KwObAniMk9nhSfyotKPQTYaUD/HB0f5muig==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-avatar@1.1.12':
- resolution: {integrity: sha512-NQCQyWC7QrDPhjMn8hUqFeU0lUrprIgm1AyMgLbzuQJibNnatdc3SSMo3/UGFu/eUkJUU1cEcKCnyhXTQzq6tA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-collection@1.1.9':
- resolution: {integrity: sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-compose-refs@1.1.3':
- resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-context@1.1.4':
- resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-dialog@1.1.16':
- resolution: {integrity: sha512-l9ok83YBclEZhbjgzt76Hw733e6cvRKPNgO6GJ/IETlufXG9p+fRu2wlvpImQvR6xdJ8h7J8J2DBvsPEiEsKMw==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-direction@1.1.2':
- resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-dismissable-layer@1.1.12':
- resolution: {integrity: sha512-MhoruH6xEzsbvOmo4TNgMfmtvRGyDZw4MDSdf4ybMHfezjqwzv6hyd4lsMzBp8K9Sn6sGzCF62x1I7BYUECXOg==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-dropdown-menu@2.1.17':
- resolution: {integrity: sha512-S6b3Jm57sY5EdDyOMLkacbB0qMnKhy1RCKZCt795ZkmtUOAvojYIZ5p7dXHIh5Cyr3jCLLI5/g64V3FKLudZmw==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-focus-guards@1.1.4':
- resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-focus-scope@1.1.9':
- resolution: {integrity: sha512-9Se8t+Zry+1rEOL7Y6l/4ANYU/TOtAtf8O2fKdwLltcaMcm6kOqYGbzO4tMFQ0bvzO920pRAoHpFZ4W85S3keQ==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-id@1.1.2':
- resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-menu@2.1.17':
- resolution: {integrity: sha512-fmbNnFyf+JYCN0DhhWnEdUTDnZD1mXaPQWivdsPIb8oOSbARfD3LIQJbLCG8a8QLCwoMxiJ7GVPIFcC8Dw8v2Q==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-popper@1.3.0':
- resolution: {integrity: sha512-9PB589e1aWZbrlFUHdz6WiPCL+xLZHQFX7oibqG/6Q0SwOkxDyQX9W/cyPa+sAPPKuC8cpLCpRczE5a/1DiwVQ==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-portal@1.1.11':
- resolution: {integrity: sha512-UEytdjgEh2tJGgD/gZK4FUx6t1rNIlM3U0DENhSrG7I75FGm1DnaDuVUWF1pWAWUwGmn1sCJ1VGHn8LhN1aTOw==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-presence@1.1.6':
- resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-primitive@2.1.5':
- resolution: {integrity: sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-roving-focus@1.1.12':
- resolution: {integrity: sha512-FvgPt1bRmg8Xt2QpF7NUZW3dE0ZQHGm41dAdgT2J2GJPoIXz+9Em3NobAxf4fupcxhgHu03E5CRiU2MWvObXyg==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-select@2.3.0':
- resolution: {integrity: sha512-mENc7WpJvJcW8hlMpzfFcHcEhTvYS5JMBmi9HVC1Q00uhBwML086MHYUV8QQdQv6lcu0Wg8dzd1RB8AFADcG/g==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-separator@1.1.9':
- resolution: {integrity: sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-slot@1.2.5':
- resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-switch@1.3.0':
- resolution: {integrity: sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-tabs@1.1.14':
- resolution: {integrity: sha512-D5jwp9JNuwDeCw3CYD2Fz+sSHo0droQjC8u75dJHe4aWr5q6yBiXZU+hurXnKudRgEpUkD5TsI6bjHPo5ThUxA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-tooltip@1.2.9':
- resolution: {integrity: sha512-u6F9MmTtBSLkiXNVDrtB/yPCZarM9smNswC24YYLV/M+bth6J3Gs3vlJezEoFwKZvPvxhCpUYdUnOsNG/0XOlA==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/react-use-callback-ref@1.1.2':
- resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-controllable-state@1.2.3':
- resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-effect-event@0.0.3':
- resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-escape-keydown@1.1.2':
- resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-is-hydrated@0.1.1':
- resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-layout-effect@1.1.2':
- resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-previous@1.1.2':
- resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-rect@1.1.2':
- resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-use-size@1.1.2':
- resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- '@radix-ui/react-visually-hidden@1.2.5':
- resolution: {integrity: sha512-tPcHNI3FajdDBFpl/Ez1m2WL0ufJqBKyHxMDBvKitopamK36WwBGOMicuMEZKkM5Wce41QxUyv6BsiqfrWBiGg==}
- peerDependencies:
- '@types/react': '*'
- '@types/react-dom': '*'
- react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
- '@types/react-dom':
- optional: true
-
- '@radix-ui/rect@1.1.2':
- resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
-
'@rolldown/binding-android-arm64@1.0.0-beta.33':
resolution: {integrity: sha512-xhDQXKftRkEULIxCddrKMR8y0YO/Y+6BKk/XrQP2B29YjV2wr8DByoEz+AHX9BfLHb2srfpdN46UquBW2QXWpQ==}
cpu: [arm64]
@@ -2223,10 +1781,6 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
- aria-hidden@1.2.6:
- resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
- engines: {node: '>=10'}
-
aria-query@5.3.2:
resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
engines: {node: '>= 0.4'}
@@ -2417,9 +1971,6 @@ packages:
resolution: {integrity: sha512-86M1y3ZeQvpZkZejQCcS+IaSWjlDUC+ORP0peScQ4uEUFCZ8bEQVz7NlJHqysoUb6w3zCjx4Mq/8/2RHhMwHYw==}
engines: {node: '>=14'}
- class-variance-authority@0.7.1:
- resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
-
cli-boxes@3.0.0:
resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==}
engines: {node: '>=10'}
@@ -2440,10 +1991,6 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
- cluster-key-slot@1.1.1:
- resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==}
- engines: {node: '>=0.10.0'}
-
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -2534,15 +2081,6 @@ packages:
supports-color:
optional: true
- debug@4.4.3:
- resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
- engines: {node: '>=6.0'}
- peerDependencies:
- supports-color: '*'
- peerDependenciesMeta:
- supports-color:
- optional: true
-
decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
@@ -2562,10 +2100,6 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
- denque@2.1.0:
- resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
- engines: {node: '>=0.10'}
-
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@@ -2586,9 +2120,6 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
- detect-node-es@1.1.0:
- resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
-
deterministic-object-hash@2.0.2:
resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==}
engines: {node: '>=18'}
@@ -2954,10 +2485,6 @@ packages:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
- get-nonce@1.0.1:
- resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==}
- engines: {node: '>=6'}
-
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
@@ -3082,10 +2609,6 @@ packages:
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
- ioredis@5.11.1:
- resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==}
- engines: {node: '>=12.22.0'}
-
ip-address@9.0.5:
resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==}
engines: {node: '>= 12'}
@@ -3936,36 +3459,6 @@ packages:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
- react-remove-scroll-bar@2.3.8:
- resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- react-remove-scroll@2.7.2:
- resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- react-style-singleton@2.2.3:
- resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
@@ -3985,14 +3478,6 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
- redis-errors@1.2.0:
- resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
- engines: {node: '>=4'}
-
- redis-parser@3.0.0:
- resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
- engines: {node: '>=4'}
-
regex-recursion@5.1.1:
resolution: {integrity: sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w==}
@@ -4241,9 +3726,6 @@ packages:
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
- standard-as-callback@2.1.0:
- resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
-
statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
@@ -4514,26 +3996,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
- use-callback-ref@1.3.3:
- resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
- use-sidecar@1.1.3:
- resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
- engines: {node: '>=10'}
- peerDependencies:
- '@types/react': '*'
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
- peerDependenciesMeta:
- '@types/react':
- optional: true
-
utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'}
@@ -5496,23 +4958,6 @@ snapshots:
'@esbuild/win32-x64@0.25.9':
optional: true
- '@floating-ui/core@1.7.5':
- dependencies:
- '@floating-ui/utils': 0.2.11
-
- '@floating-ui/dom@1.7.6':
- dependencies:
- '@floating-ui/core': 1.7.5
- '@floating-ui/utils': 0.2.11
-
- '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@floating-ui/dom': 1.7.6
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
-
- '@floating-ui/utils@0.2.11': {}
-
'@iconify-json/simple-icons@1.2.48':
dependencies:
'@iconify/types': 2.0.0
@@ -5594,8 +5039,6 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
- '@ioredis/commands@1.10.0': {}
-
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -5775,393 +5218,6 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
- '@radix-ui/number@1.1.2': {}
-
- '@radix-ui/primitive@1.1.4': {}
-
- '@radix-ui/react-arrow@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-avatar@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-collection@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-compose-refs@1.1.3(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-context@1.1.4(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-dialog@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- aria-hidden: 1.2.6
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-direction@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-focus-guards@1.1.4(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-focus-scope@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-id@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- aria-hidden: 1.2.6
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-popper@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-arrow': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-rect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/rect': 1.1.2
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-portal@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-presence@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-primitive@2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-select@2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/number': 1.1.2
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- aria-hidden: 1.2.6
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-separator@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-slot@1.2.5(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-switch@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-tabs@1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-tooltip@1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/primitive': 1.1.4
- '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/react-use-callback-ref@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-controllable-state@1.2.3(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@18.3.1)
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-effect-event@0.0.3(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-layout-effect@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-previous@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-rect@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/rect': 1.1.2
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-use-size@1.1.2(@types/react@18.3.31)(react@18.3.1)':
- dependencies:
- '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1)
- react: 18.3.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
- dependencies:
- '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- react: 18.3.1
- react-dom: 18.3.1(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
- '@types/react-dom': 18.3.7(@types/react@18.3.31)
-
- '@radix-ui/rect@1.1.2': {}
-
'@rolldown/binding-android-arm64@1.0.0-beta.33':
optional: true
@@ -6810,10 +5866,6 @@ snapshots:
argparse@2.0.1: {}
- aria-hidden@1.2.6:
- dependencies:
- tslib: 2.8.1
-
aria-query@5.3.2: {}
arktype@2.1.20:
@@ -7079,10 +6131,6 @@ snapshots:
dependencies:
ip-regex: 5.0.0
- class-variance-authority@0.7.1:
- dependencies:
- clsx: 2.1.1
-
cli-boxes@3.0.0: {}
cli-cursor@5.0.0:
@@ -7099,8 +6147,6 @@ snapshots:
clsx@2.1.1: {}
- cluster-key-slot@1.1.1: {}
-
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -7168,10 +6214,6 @@ snapshots:
dependencies:
ms: 2.1.3
- debug@4.4.3:
- dependencies:
- ms: 2.1.3
-
decode-named-character-reference@1.2.0:
dependencies:
character-entities: 2.0.2
@@ -7187,8 +6229,6 @@ snapshots:
delayed-stream@1.0.0: {}
- denque@2.1.0: {}
-
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -7199,8 +6239,6 @@ snapshots:
detect-libc@2.0.4: {}
- detect-node-es@1.1.0: {}
-
deterministic-object-hash@2.0.2:
dependencies:
base-64: 1.0.0
@@ -7517,8 +6555,6 @@ snapshots:
hasown: 2.0.2
math-intrinsics: 1.1.0
- get-nonce@1.0.1: {}
-
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@@ -7700,18 +6736,6 @@ snapshots:
ini@1.3.8:
optional: true
- ioredis@5.11.1:
- dependencies:
- '@ioredis/commands': 1.10.0
- cluster-key-slot: 1.1.1
- debug: 4.4.3
- denque: 2.1.0
- redis-errors: 1.2.0
- redis-parser: 3.0.0
- standard-as-callback: 2.1.0
- transitivePeerDependencies:
- - supports-color
-
ip-address@9.0.5:
dependencies:
jsbn: 1.1.0
@@ -8648,33 +7672,6 @@ snapshots:
react-refresh@0.17.0: {}
- react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1):
- dependencies:
- react: 18.3.1
- react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1)
- tslib: 2.8.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- react-remove-scroll@2.7.2(@types/react@18.3.31)(react@18.3.1):
- dependencies:
- react: 18.3.1
- react-remove-scroll-bar: 2.3.8(@types/react@18.3.31)(react@18.3.1)
- react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1)
- tslib: 2.8.1
- use-callback-ref: 1.3.3(@types/react@18.3.31)(react@18.3.1)
- use-sidecar: 1.1.3(@types/react@18.3.31)(react@18.3.1)
- optionalDependencies:
- '@types/react': 18.3.31
-
- react-style-singleton@2.2.3(@types/react@18.3.31)(react@18.3.1):
- dependencies:
- get-nonce: 1.0.1
- react: 18.3.1
- tslib: 2.8.1
- optionalDependencies:
- '@types/react': 18.3.31
-
react@18.3.1:
dependencies:
loose-envify: 1.4.0
@@ -8696,12 +7693,6 @@ snapshots:
readdirp@4.1.2: {}
- redis-errors@1.2.0: {}
-
- redis-parser@3.0.0:
- dependencies:
- redis-errors: 1.2.0
-
regex-recursion@5.1.1:
dependencies:
regex: 5.1.1
@@ -9056,8 +8047,6 @@ snapshots:
stackback@0.0.2: {}
- standard-as-callback@2.1.0: {}
-
statuses@2.0.1: {}
std-env@3.9.0: {}
@@ -9282,7 +8271,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
- tslib@2.8.1: {}
+ tslib@2.8.1:
+ optional: true
tsx@4.20.4:
dependencies:
@@ -9377,21 +8367,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
- use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1):
- dependencies:
- react: 18.3.1
- tslib: 2.8.1
- optionalDependencies:
- '@types/react': 18.3.31
-
- use-sidecar@1.1.3(@types/react@18.3.31)(react@18.3.1):
- dependencies:
- detect-node-es: 1.1.0
- react: 18.3.1
- tslib: 2.8.1
- optionalDependencies:
- '@types/react': 18.3.31
-
utf-8-validate@5.0.10:
dependencies:
node-gyp-build: 4.8.4
diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx
deleted file mode 100644
index 291931b..0000000
--- a/src/components/dashboard/Dashboard.tsx
+++ /dev/null
@@ -1,406 +0,0 @@
-// 🤠 Heady Dashboard — React replacement for the Alpine-driven home page.
-
-import {
- Activity,
- ArrowRight,
- CheckCircle2,
- CircleDot,
- Database,
- Globe,
- HardDrive,
- KeyRound,
- Network,
- Plus,
- Server,
- Shield,
- Terminal,
- Users,
- Wifi,
- XCircle,
-} from 'lucide-react';
-import * as React from 'react';
-import { AppShell, type SessionUser } from '@/components/shell/AppShell';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-
-export interface Machine {
- id: string;
- name: string;
- ip_address: string;
- os: string;
- online: boolean;
-}
-
-export interface Session {
- id: string;
- node_name: string;
- user_email: string;
- protocol: 'ssh' | 'rdp' | 'vnc' | 'telnet' | 'kubernetes';
- status: string;
-}
-
-export interface DashboardProps {
- machines: Machine[];
- activeSessions: Session[];
-}
-
-export function DashboardPage(props: DashboardProps) {
- return (
-
- {(user) => }
-
- );
-}
-
-function DashboardBody({
- user,
- machines,
- activeSessions,
-}: DashboardProps & { user: SessionUser }) {
- const onlineMachines = machines.filter((m) => m.online).length;
- const systemHealth = {
- headscale: true,
- database: true,
- oidc: true,
- remoteAccess: true,
- };
- const everythingHealthy = Object.values(systemHealth).every((v) => v);
-
- return (
-
- {/* ─── Welcome header ─────────────────────────────── */}
-
-
- {/* ─── Stats grid ─────────────────────────────────── */}
-
- }
- label="Online Machines"
- value={`${onlineMachines}`}
- sub={`of ${machines.length}`}
- tone="emerald"
- />
- }
- label="Active Sessions"
- value={`${activeSessions.length}`}
- tone="sky"
- />
- }
- label="System Health"
- value={everythingHealthy ? 'Healthy' : 'Degraded'}
- tone={everythingHealthy ? 'emerald' : 'amber'}
- />
- }
- label="Network"
- value="Online"
- tone="violet"
- />
-
-
- {/* ─── Main grid: machines + sidebar ─────────────── */}
-
-
- );
-}
-
-/* ────────────────────────────────────────── Stat tile ──── */
-
-function StatCard({
- icon,
- label,
- value,
- sub,
- tone,
-}: {
- icon: React.ReactNode;
- label: string;
- value: string;
- sub?: string;
- tone: 'emerald' | 'sky' | 'amber' | 'violet';
-}) {
- const toneClasses: Record = {
- emerald: 'bg-emerald-500/10 text-emerald-400',
- sky: 'bg-sky-500/10 text-sky-400',
- amber: 'bg-amber-500/10 text-amber-400',
- violet: 'bg-violet-500/10 text-violet-400',
- };
- return (
-
-
-
- {icon}
-
-
-
- {label}
-
-
- {value}
- {sub && (
-
- {sub}
-
- )}
-
-
-
-
- );
-}
-
-/* ────────────────────────────────────────── Machines ──── */
-
-function MachinesOverview({
- machines,
- className,
-}: {
- machines: Machine[];
- className?: string;
-}) {
- const visible = machines.slice(0, 5);
- const overflow = machines.length - visible.length;
- return (
-
-
-
-
-
- Machines
-
- Connected devices in your tailnet
-
-
-
- View all
-
-
-
-
-
- {visible.length === 0 ? (
- }
- title="No machines yet"
- description="Connect your first device to see it here."
- />
- ) : (
-
- {visible.map((m) => (
-
-
-
-
{m.name}
-
- {m.ip_address}
-
-
-
- {m.os}
-
-
- {m.online ? 'Online' : 'Offline'}
-
-
- ))}
-
- )}
- {overflow > 0 && (
-
- and {overflow} more…
-
- )}
-
-
- );
-}
-
-/* ────────────────────────────────────────── Sessions ──── */
-
-function ActiveSessionsCard({ sessions }: { sessions: Session[] }) {
- return (
-
-
-
-
- Active Sessions
-
-
-
- {sessions.length === 0 ? (
- No active sessions.
- ) : (
- sessions.slice(0, 4).map((s) => (
-
-
-
- {s.protocol}
-
- {s.node_name}
-
-
- {s.user_email.split('@')[0]}
-
-
- ))
- )}
-
-
- );
-}
-
-/* ────────────────────────────────────── System health ──── */
-
-function SystemHealthCard({
- health,
-}: {
- health: { headscale: boolean; database: boolean; oidc: boolean; remoteAccess: boolean };
-}) {
- const rows: { label: string; ok: boolean; icon: React.ReactNode }[] = [
- { label: 'Headscale', ok: health.headscale, icon: },
- { label: 'Database', ok: health.database, icon: },
- { label: 'OIDC Auth', ok: health.oidc, icon: },
- { label: 'Remote Access', ok: health.remoteAccess, icon: },
- ];
- return (
-
-
-
-
- System Health
-
-
-
- {rows.map((r) => (
-
-
- {r.icon}
- {r.label}
-
- {r.ok ? (
-
-
- Healthy
-
- ) : (
-
-
- Degraded
-
- )}
-
- ))}
-
-
- );
-}
-
-/* ────────────────────────────────────── Quick actions ──── */
-
-function QuickActionsCard() {
- const items: { href: string; label: string; icon: React.ReactNode }[] = [
- { href: '/machines/add', label: 'Add Machine', icon: },
- { href: '/acls', label: 'Configure ACLs', icon: },
- { href: '/dns', label: 'Manage DNS', icon: },
- { href: '/users', label: 'User Management', icon: },
- ];
- return (
-
-
- Quick Actions
-
-
- {items.map((it) => (
-
-
- {it.icon}
- {it.label}
-
-
- ))}
-
-
- );
-}
-
-/* ────────────────────────────────────── Empty state ──── */
-
-function EmptyState({
- icon,
- title,
- description,
-}: {
- icon: React.ReactNode;
- title: string;
- description: string;
-}) {
- return (
-
- {icon}
-
{title}
-
{description}
-
- );
-}
-
diff --git a/src/components/dns/DnsPage.tsx b/src/components/dns/DnsPage.tsx
deleted file mode 100644
index 739b8aa..0000000
--- a/src/components/dns/DnsPage.tsx
+++ /dev/null
@@ -1,512 +0,0 @@
-// 🤠 Heady DNS page — React + shadcn rebuild.
-
-import {
- Globe,
- Network,
- Plus,
- RotateCcw,
- Save,
- Server,
- Trash2,
-} from 'lucide-react';
-import * as React from 'react';
-import { AppShell } from '@/components/shell/AppShell';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { Switch } from '@/components/ui/switch';
-
-export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'TXT' | 'MX';
-
-export interface DnsRecord {
- name: string;
- type: DnsRecordType;
- value: string;
-}
-
-export interface DnsConfig {
- prefixes: string[];
- magicDns: boolean;
- baseDomain: string;
- nameservers: string[];
- splitDns: Record;
- searchDomains: string[];
- overrideDns: boolean;
- extraRecords: DnsRecord[];
-}
-
-export interface DnsPageProps {
- initial: DnsConfig;
- canEdit?: boolean;
-}
-
-export function DnsPage(props: DnsPageProps) {
- return (
-
- {() => }
-
- );
-}
-
-function DnsBody({ initial, canEdit = true }: DnsPageProps) {
- const [config, setConfig] = React.useState(initial);
- const [dirty, setDirty] = React.useState(false);
-
- const update = (key: K, value: DnsConfig[K]) => {
- setConfig((c) => ({ ...c, [key]: value }));
- setDirty(true);
- };
-
- const reset = () => {
- setConfig(initial);
- setDirty(false);
- };
-
- const save = () => {
- // TODO: PATCH /api/dns once the backend lands.
- console.log('Would save DNS config', config);
- setDirty(false);
- };
-
- return (
-
-
-
-
DNS
-
- Magic DNS, nameservers, search domains, and custom records for your
- tailnet.
-
-
-
-
-
- Discard
-
-
-
- Save changes
-
-
-
-
- {/* Magic DNS toggle row */}
-
-
-
-
Magic DNS
-
- Auto-resolve devices by name on{' '}
-
- {config.baseDomain || ''}
-
- .
-
-
- update('magicDns', v)}
- disabled={!canEdit}
- />
-
-
-
- {/* Base domain + override toggle */}
-
-
- Base domain
-
- Suffix appended to every Magic DNS hostname.
-
-
-
-
-
- Domain
-
- update('baseDomain', e.target.value)}
- placeholder="heady.local"
- disabled={!canEdit}
- />
-
-
-
-
Override local DNS
-
- Force clients to use these nameservers.
-
-
-
update('overrideDns', v)}
- disabled={!canEdit}
- />
-
-
-
-
- {/* Tailnet prefixes (read-only) */}
-
-
-
-
- Tailnet prefixes
-
-
- Allocated by Headscale. Read-only.
-
-
-
- {config.prefixes.map((p) => (
-
- {p}
-
- ))}
-
-
-
- {/* String list cards */}
-
- }
- values={config.nameservers}
- placeholder="1.1.1.1"
- onChange={(v) => update('nameservers', v)}
- disabled={!canEdit}
- />
- }
- values={config.searchDomains}
- placeholder="company.com"
- onChange={(v) => update('searchDomains', v)}
- disabled={!canEdit}
- />
-
-
- {/* Split DNS */}
-
update('splitDns', v)}
- disabled={!canEdit}
- />
-
- {/* Extra records */}
- update('extraRecords', v)}
- disabled={!canEdit}
- />
-
- );
-}
-
-/* ───── String list editor (nameservers, search domains) ───── */
-
-function StringListCard({
- title,
- description,
- icon,
- values,
- placeholder,
- onChange,
- disabled,
-}: {
- title: string;
- description: string;
- icon: React.ReactNode;
- values: string[];
- placeholder: string;
- onChange: (v: string[]) => void;
- disabled?: boolean;
-}) {
- const [draft, setDraft] = React.useState('');
- const add = () => {
- const v = draft.trim();
- if (!v || values.includes(v)) return;
- onChange([...values, v]);
- setDraft('');
- };
- const remove = (i: number) => {
- onChange(values.filter((_, idx) => idx !== i));
- };
- return (
-
-
- {icon}{title}
- {description}
-
-
- {values.length === 0 && (
- No entries yet.
- )}
-
- {values.map((v, i) => (
-
- {v}
- remove(i)}
- disabled={disabled}
- aria-label={`Remove ${v}`}
- >
-
-
-
- ))}
-
-
-
setDraft(e.target.value)}
- placeholder={placeholder}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault();
- add();
- }
- }}
- disabled={disabled}
- />
-
-
- Add
-
-
-
-
- );
-}
-
-/* ───── Split DNS ───── */
-
-function SplitDnsCard({
- rules,
- onChange,
- disabled,
-}: {
- rules: Record;
- onChange: (v: Record) => void;
- disabled?: boolean;
-}) {
- const [domain, setDomain] = React.useState('');
- const [servers, setServers] = React.useState('');
-
- const add = () => {
- const d = domain.trim();
- const s = servers
- .split(',')
- .map((x) => x.trim())
- .filter(Boolean);
- if (!d || s.length === 0) return;
- onChange({ ...rules, [d]: s });
- setDomain('');
- setServers('');
- };
-
- const remove = (key: string) => {
- const next = { ...rules };
- delete next[key];
- onChange(next);
- };
-
- const entries = Object.entries(rules);
- return (
-
-
- Split DNS
-
- Per-domain resolvers. Queries for these domains skip the global list.
-
-
-
- {entries.length === 0 ? (
- No split DNS rules.
- ) : (
-
- {entries.map(([d, ips]) => (
-
-
-
{d}
-
- {ips.join(', ')}
-
-
- remove(d)}
- disabled={disabled}
- aria-label={`Remove ${d}`}
- >
-
-
-
- ))}
-
- )}
-
-
-
- );
-}
-
-/* ───── Extra records ───── */
-
-function ExtraRecordsCard({
- records,
- onChange,
- disabled,
-}: {
- records: DnsRecord[];
- onChange: (v: DnsRecord[]) => void;
- disabled?: boolean;
-}) {
- const [name, setName] = React.useState('');
- const [type, setType] = React.useState('A');
- const [value, setValue] = React.useState('');
-
- const add = () => {
- if (!name.trim() || !value.trim()) return;
- onChange([...records, { name: name.trim(), type, value: value.trim() }]);
- setName('');
- setValue('');
- };
- const remove = (i: number) => onChange(records.filter((_, idx) => idx !== i));
-
- return (
-
-
- Custom records
-
- Extra A/AAAA/CNAME/TXT/MX records served alongside Magic DNS.
-
-
-
- {records.length === 0 ? (
-
- No custom records.
-
- ) : (
-
-
-
-
- Name
- Type
- Value
-
-
-
-
- {records.map((r, i) => (
- // biome-ignore lint/suspicious/noArrayIndexKey: stable position
-
- {r.name}
-
-
- {r.type}
-
-
-
- {r.value}
-
-
- remove(i)}
- disabled={disabled}
- aria-label={`Remove ${r.name}`}
- >
-
-
-
-
- ))}
-
-
-
- )}
-
-
-
- );
-}
diff --git a/src/components/machines/MachinesPage.tsx b/src/components/machines/MachinesPage.tsx
deleted file mode 100644
index 229c596..0000000
--- a/src/components/machines/MachinesPage.tsx
+++ /dev/null
@@ -1,356 +0,0 @@
-// 🤠 Heady Machines page — React + shadcn rebuild.
-
-import {
- CircleDot,
- Filter,
- HardDrive,
- MoreHorizontal,
- Plus,
- RefreshCw,
- Search,
- Server,
- Tag,
-} from 'lucide-react';
-import * as React from 'react';
-import { AppShell, type SessionUser } from '@/components/shell/AppShell';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Input } from '@/components/ui/input';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-
-export interface MachineRow {
- id: string;
- name: string;
- hostname?: string;
- ip_address: string;
- online: boolean;
- expired?: boolean;
- os: 'linux' | 'windows' | 'macos' | 'android' | 'ios' | 'unknown';
- user?: string;
- tags?: string[];
- last_seen?: string;
-}
-
-export interface MachinesPageProps {
- machines: MachineRow[];
-}
-
-export function MachinesPage(props: MachinesPageProps) {
- return (
-
- {(user) => }
-
- );
-}
-
-function MachinesBody({
- user,
- machines,
-}: MachinesPageProps & { user: SessionUser }) {
- const [search, setSearch] = React.useState('');
- const [statusFilter, setStatusFilter] = React.useState('all');
- const [osFilter, setOsFilter] = React.useState('all');
-
- const allOsValues = React.useMemo(() => {
- const set = new Set();
- machines.forEach((m) => set.add(m.os));
- return Array.from(set).sort();
- }, [machines]);
-
- const filtered = React.useMemo(() => {
- const needle = search.trim().toLowerCase();
- return machines.filter((m) => {
- if (statusFilter === 'online' && !m.online) return false;
- if (statusFilter === 'offline' && m.online) return false;
- if (osFilter !== 'all' && m.os !== osFilter) return false;
- if (!needle) return true;
- return (
- m.name.toLowerCase().includes(needle) ||
- m.ip_address.toLowerCase().includes(needle) ||
- (m.user ?? '').toLowerCase().includes(needle) ||
- (m.tags ?? []).some((t) => t.toLowerCase().includes(needle))
- );
- });
- }, [machines, search, statusFilter, osFilter]);
-
- const onlineCount = machines.filter((m) => m.online).length;
- const expiredCount = machines.filter((m) => m.expired).length;
-
- return (
-
-
-
- {/* Stat strip */}
-
- } />
- }
- />
- }
- />
- 0 ? 'amber' : 'muted'}
- icon={ }
- />
-
-
- {/* Filter bar */}
-
-
-
-
- setSearch(e.target.value)}
- className="pl-8"
- />
-
-
-
-
-
-
- All statuses
- Online
- Offline
-
-
-
-
-
-
-
- All OS
- {allOsValues.map((os) => (
-
- {os}
-
- ))}
-
-
-
-
-
- {/* Results */}
-
-
-
- Devices
-
- {filtered.length} of {machines.length} shown
-
-
-
-
-
- {filtered.length === 0 ? (
-
- ) : (
-
- )}
-
-
-
- );
-}
-
-/* ───── stat ───── */
-function Stat({
- label,
- value,
- icon,
- tone = 'sky',
-}: {
- label: string;
- value: number;
- icon: React.ReactNode;
- tone?: 'sky' | 'emerald' | 'amber' | 'muted';
-}) {
- const tones: Record = {
- sky: 'bg-sky-500/10 text-sky-400',
- emerald: 'bg-emerald-500/10 text-emerald-400',
- amber: 'bg-amber-500/10 text-amber-400',
- muted: 'bg-muted text-muted-foreground',
- };
- return (
-
-
-
- {icon}
-
-
-
- {label}
-
-
{value}
-
-
-
- );
-}
-
-/* ───── table ───── */
-function MachinesTable({ machines }: { machines: MachineRow[] }) {
- return (
-
-
-
-
- Status
- Name
- IP
- OS
- User
- Tags
-
-
-
-
- {machines.map((m) => (
-
-
-
- {m.online ? 'Online' : 'Offline'}
-
- {m.expired && (
-
- expired
-
- )}
-
-
- {m.name}
- {m.hostname && m.hostname !== m.name && (
-
- {m.hostname}
-
- )}
-
- {m.ip_address}
-
- {m.os}
-
-
- {m.user ?? '—'}
-
-
- {m.tags && m.tags.length > 0 ? (
-
- {m.tags.map((t) => (
-
-
- {t}
-
- ))}
-
- ) : (
- —
- )}
-
-
-
-
-
- ))}
-
-
-
- );
-}
-
-function MachineRowMenu({ machine }: { machine: MachineRow }) {
- return (
-
-
-
-
-
-
-
- {machine.name}
-
- {
- navigator.clipboard?.writeText(machine.ip_address).catch(() => {});
- }}
- >
- Copy IP
-
-
- Details
-
-
- Open terminal
-
-
-
- Disconnect
-
-
-
- );
-}
-
-function EmptyResult() {
- return (
-
-
-
No matching machines
-
Try adjusting your search or filters.
-
- );
-}
diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx
deleted file mode 100644
index e688b3f..0000000
--- a/src/components/settings/SettingsPage.tsx
+++ /dev/null
@@ -1,648 +0,0 @@
-// ⚙️ Heady Settings page — React + shadcn rebuild with tabs.
-
-import {
- CheckCircle2,
- FileText,
- KeyRound,
- Plus,
- Server,
- Settings,
- Shield,
- Trash2,
- UserCheck,
- XCircle,
-} from 'lucide-react';
-import * as React from 'react';
-import { AppShell, type SessionUser } from '@/components/shell/AppShell';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import { Input } from '@/components/ui/input';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-import { Switch } from '@/components/ui/switch';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
-
-export interface AuthKey {
- id: string;
- keyPrefix: string;
- reusable: boolean;
- ephemeral: boolean;
- used: boolean;
- expiration: string;
- createdAt: string;
- user: { id: string; name: string; email: string };
-}
-
-export interface SettingsData {
- server: {
- version: string;
- url: string;
- publicUrl: string;
- grpcListenAddr: string;
- metricsListenAddr: string;
- };
- oidc: {
- enabled: boolean;
- issuer: string;
- clientId: string;
- stripEmailDomain: boolean;
- scope: string[];
- allowedDomains: string[];
- allowedGroups: string[];
- };
- database: { type: string; sqlite: { path: string } };
- tls: { letsencrypt: { hostname: string; challengeType: string } };
- log: { level: string; format: string };
-}
-
-export interface Permissions {
- canGenerateAuthKeys: boolean;
- canModifyConfig: boolean;
- canViewLogs: boolean;
-}
-
-/** Heady runtime info — pulled from process env + the session store. */
-export interface HeadyRuntime {
- sessionStore: 'redis' | 'in-memory';
- sessionLifetime: string;
- sessionCookieName: string;
- cookieSecure: boolean;
- activeSessions: number;
- nodeVersion: string;
- processUptime: number;
-}
-
-export interface SettingsPageProps {
- settings: SettingsData;
- authKeys: AuthKey[];
- permissions: Permissions;
- heady: HeadyRuntime;
-}
-
-export function SettingsPage(props: SettingsPageProps) {
- return (
-
- {(user) => }
-
- );
-}
-
-function SettingsBody({
- me,
- settings,
- authKeys,
- permissions,
- heady,
-}: SettingsPageProps & { me: SessionUser }) {
- return (
-
-
-
- {/* Headline strip */}
-
- }
- label="Headscale"
- value={
- settings.server.version.includes('connect')
- ? 'not connected'
- : `v${settings.server.version}`
- }
- tone={settings.server.version.includes('connect') ? 'amber' : 'sky'}
- />
- }
- label="OIDC"
- value={settings.oidc.enabled ? 'Enabled' : 'Disabled'}
- tone={settings.oidc.enabled ? 'emerald' : 'amber'}
- />
- }
- label="Sessions"
- value={`${heady.activeSessions} active`}
- tone="sky"
- />
- }
- label="Session store"
- value={heady.sessionStore}
- tone={heady.sessionStore === 'redis' ? 'emerald' : 'amber'}
- />
-
-
-
-
-
-
- Overview
-
-
-
- Auth Keys
-
-
-
- OIDC
-
-
-
- Advanced
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function HeadlineStat({
- icon,
- label,
- value,
- tone = 'sky',
-}: {
- icon: React.ReactNode;
- label: string;
- value: string;
- tone?: 'sky' | 'emerald' | 'amber';
-}) {
- const tones = {
- sky: 'bg-sky-500/10 text-sky-400',
- emerald: 'bg-emerald-500/10 text-emerald-400',
- amber: 'bg-amber-500/10 text-amber-400',
- };
- return (
-
-
-
- {icon}
-
-
-
- {label}
-
-
{value}
-
-
-
- );
-}
-
-/* ───── Overview tab ───── */
-
-function OverviewTab({
- settings,
- heady,
-}: {
- settings: SettingsData;
- heady: HeadyRuntime;
-}) {
- // Heady-side rows — these we know for certain.
- const headyRows: { label: string; value: string }[] = [
- { label: 'Public URL', value: settings.server.publicUrl },
- { label: 'Session store', value: heady.sessionStore },
- { label: 'Session lifetime', value: heady.sessionLifetime },
- { label: 'Session cookie', value: heady.sessionCookieName },
- {
- label: 'Cookie secure',
- value: heady.cookieSecure ? 'true' : 'false (dev)',
- },
- { label: 'Active sessions', value: String(heady.activeSessions) },
- { label: 'Node.js', value: heady.nodeVersion },
- { label: 'Process uptime', value: `${heady.processUptime}s` },
- ];
-
- // Headscale-side rows — placeholders until the integration lands.
- const headscaleRows: { label: string; value: string }[] = [
- { label: 'Version', value: settings.server.version },
- { label: 'URL', value: settings.server.url },
- { label: 'gRPC listen', value: settings.server.grpcListenAddr },
- { label: 'Metrics listen', value: settings.server.metricsListenAddr },
- {
- label: 'Database',
- value:
- settings.database.type.includes('connect')
- ? settings.database.type
- : `${settings.database.type} (${settings.database.sqlite.path})`,
- },
- { label: 'TLS hostname', value: settings.tls.letsencrypt.hostname },
- { label: 'TLS challenge', value: settings.tls.letsencrypt.challengeType },
- ];
-
- return (
-
-
-
- Heady runtime
-
- Live state of this Heady instance.
-
-
-
-
- {headyRows.map((r) => (
-
-
{r.label}
-
- {r.value}
-
-
- ))}
-
-
-
-
-
-
- Headscale configuration
-
- Populated from the Headscale API once it's wired. Until then,
- placeholder values are shown in dimmed text.
-
-
-
-
- {headscaleRows.map((r) => {
- const isPlaceholder = r.value.includes('connect');
- return (
-
-
{r.label}
-
- {r.value}
-
-
- );
- })}
-
-
-
-
- );
-}
-
-/* ───── Auth keys tab ───── */
-
-function AuthKeysTab({
- authKeys,
- canCreate,
-}: {
- authKeys: AuthKey[];
- canCreate: boolean;
-}) {
- const now = Date.now();
- return (
-
-
-
- Pre-auth keys
-
- Issue tokens devices can use to join the tailnet without OIDC.
-
-
-
-
- Generate key
-
-
-
- {authKeys.length === 0 ? (
-
- No auth keys yet.
-
- ) : (
-
-
-
-
- Key
- User
- Flags
- Expires
- Status
-
-
-
-
- {authKeys.map((k) => {
- const expired = new Date(k.expiration).getTime() < now;
- return (
-
-
- {k.keyPrefix}…
-
-
- {k.user.name}
- {k.user.email}
-
-
-
- {k.reusable && reusable }
- {k.ephemeral && ephemeral }
- {!k.reusable && !k.ephemeral && (
- —
- )}
-
-
-
- {new Date(k.expiration).toLocaleDateString()}
-
-
- {expired ? (
- expired
- ) : k.used ? (
- used
- ) : (
- active
- )}
-
-
-
-
-
-
-
- );
- })}
-
-
-
- )}
-
-
- );
-}
-
-/* ───── OIDC tab ───── */
-
-function OidcTab({
- oidc,
- canEdit,
-}: {
- oidc: SettingsData['oidc'];
- canEdit: boolean;
-}) {
- const [enabled, setEnabled] = React.useState(oidc.enabled);
- const [issuer, setIssuer] = React.useState(oidc.issuer);
- const [clientId, setClientId] = React.useState(oidc.clientId);
- const [strip, setStrip] = React.useState(oidc.stripEmailDomain);
-
- return (
-
-
-
-
-
OIDC authentication
-
- Identity provider for tailnet user accounts.
-
-
-
-
-
-
-
-
- Provider
-
- OIDC issuer + client. Secrets are managed via environment.
-
-
-
-
- setIssuer(e.target.value)}
- disabled={!canEdit || !enabled}
- />
-
-
- setClientId(e.target.value)}
- disabled={!canEdit || !enabled}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Restrictions
-
- Limit who can log in via OIDC.
-
-
-
-
-
-
-
-
- );
-}
-
-function RestrictionList({
- title,
- values,
- hint,
-}: {
- title: string;
- values: string[];
- hint: string;
-}) {
- return (
-
-
-
{title}
-
{values.length}
-
-
{hint}
-
- {values.length === 0 ? (
- none
- ) : (
- values.map((v) => (
-
- {v}
-
- ))
- )}
-
-
- );
-}
-
-function Field({
- label,
- children,
-}: {
- label: string;
- children: React.ReactNode;
-}) {
- return (
-
- {label}
- {children}
-
- );
-}
-
-/* ───── Advanced tab ───── */
-
-function AdvancedTab({
- settings,
- canEdit,
-}: {
- settings: SettingsData;
- canEdit: boolean;
-}) {
- const [logLevel, setLogLevel] = React.useState(settings.log.level);
- const [logFormat, setLogFormat] = React.useState(settings.log.format);
-
- return (
-
-
-
- Logging
- Verbosity and format of Headscale logs.
-
-
-
-
-
-
-
-
- trace
- debug
- info
- warn
- error
-
-
-
-
-
-
-
-
-
- text
- json
-
-
-
-
-
-
-
-
-
-
- Health checks
-
- Runtime probes on the Headscale instance.
-
-
-
-
-
-
-
-
-
- );
-}
-
-function HealthRow({ ok, label }: { ok: boolean; label: string }) {
- return (
-
- {label}
- {ok ? (
-
-
- OK
-
- ) : (
-
-
- Degraded
-
- )}
-
- );
-}
diff --git a/src/components/shell/AppShell.tsx b/src/components/shell/AppShell.tsx
deleted file mode 100644
index 4eb4a6f..0000000
--- a/src/components/shell/AppShell.tsx
+++ /dev/null
@@ -1,317 +0,0 @@
-// 🤠 Heady App Shell — React replacement for the Alpine-driven Layout chrome.
-//
-// One component owns: top nav, user menu, auth gating, and the page slot.
-// Fetches /api/auth/profile on mount, gates content behind authorization,
-// passes the loaded user down to the page via render prop.
-
-import {
- Activity,
- Bell,
- GitBranch,
- Globe,
- LayoutDashboard,
- LogOut,
- Network,
- Server,
- Settings,
- Shield,
- Terminal,
- Users,
-} from 'lucide-react';
-import * as React from 'react';
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { cn } from '@/lib/utils';
-
-export interface SessionUser {
- email: string;
- name: string;
- role: string;
- role_description: string;
- picture?: string;
- groups: string[];
- capabilities: string[];
-}
-
-type AuthState =
- | { status: 'loading' }
- | { status: 'unauthenticated' }
- | { status: 'unauthorized'; user: SessionUser }
- | { status: 'authenticated'; user: SessionUser };
-
-const NAV_ITEMS: { href: string; label: string; icon: React.ReactNode }[] = [
- { href: '/', label: 'Dashboard', icon: },
- { href: '/machines', label: 'Machines', icon: },
- { href: '/terminal', label: 'Terminal', icon: },
- { href: '/acls', label: 'ACLs', icon: },
- { href: '/dns', label: 'DNS', icon: },
- { href: '/users', label: 'Users', icon: },
- { href: '/settings', label: 'Settings', icon: },
-];
-
-interface AppShellProps {
- currentPath?: string;
- requiredRole?: string;
- /** Render-prop receives the authenticated user. */
- children: (user: SessionUser) => React.ReactNode;
-}
-
-export function AppShell({
- currentPath = typeof window !== 'undefined' ? window.location.pathname : '/',
- requiredRole = 'member',
- children,
-}: AppShellProps) {
- const [auth, setAuth] = React.useState({ status: 'loading' });
-
- React.useEffect(() => {
- (async () => {
- try {
- const r = await fetch('/api/auth/profile');
- const data = await r.json();
- if (!data.authenticated || !data.user) {
- setAuth({ status: 'unauthenticated' });
- return;
- }
- const user = data.user as SessionUser;
- const hierarchy = [
- 'owner',
- 'admin',
- 'network_admin',
- 'it_admin',
- 'auditor',
- 'member',
- ];
- const userIdx = hierarchy.indexOf(user.role);
- const reqIdx = hierarchy.indexOf(requiredRole);
- if (userIdx < 0 || reqIdx < 0 || userIdx > reqIdx) {
- setAuth({ status: 'unauthorized', user });
- return;
- }
- setAuth({ status: 'authenticated', user });
- } catch (err) {
- console.error('Auth check failed', err);
- setAuth({ status: 'unauthenticated' });
- }
- })();
- }, [requiredRole]);
-
- async function logout() {
- try {
- await fetch('/api/auth/logout', { method: 'POST' });
- } finally {
- window.location.href = '/login';
- }
- }
-
- return (
-
-
-
- {auth.status === 'loading' && }
- {auth.status === 'unauthenticated' && }
- {auth.status === 'unauthorized' && (
-
- )}
- {auth.status === 'authenticated' && children(auth.user)}
-
-
- );
-}
-
-/* ─────────────────────────────────────────── top nav ─────────── */
-
-function TopNav({
- currentPath,
- user,
- onLogout,
-}: {
- currentPath: string;
- user: SessionUser | null;
- onLogout: () => void;
-}) {
- return (
-
-
-
- 🤠
- Heady
-
-
- {NAV_ITEMS.map((item) => {
- const active =
- item.href === '/'
- ? currentPath === '/'
- : currentPath.startsWith(item.href);
- return (
-
- {item.icon}
- {item.label}
-
- );
- })}
-
-
-
-
-
- {user && }
-
-
-
- );
-}
-
-function UserMenu({
- user,
- onLogout,
-}: {
- user: SessionUser;
- onLogout: () => void;
-}) {
- const initials = user.name
- .split(' ')
- .map((p) => p[0])
- .filter(Boolean)
- .slice(0, 2)
- .join('')
- .toUpperCase();
- return (
-
-
-
-
- {user.picture && }
- {initials || '?'}
-
-
- {user.email}
- {user.role}
-
-
-
-
-
-
- {user.name}
-
- {user.email}
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
- Users
-
-
-
-
-
- Sign out
-
-
-
- );
-}
-
-/* ─────────────────────────────────────────── auth states ─────── */
-
-function AuthLoading() {
- return (
-
- );
-}
-
-function AuthRequired() {
- return (
-
-
-
Authentication required
-
- Sign in with Authentik to manage your Headscale deployment.
-
-
{
- window.location.href = '/api/auth/login';
- }}
- >
-
- Sign in with Authentik
-
-
- );
-}
-
-function AccessDenied({
- user,
- requiredRole,
- onLogout,
-}: {
- user: SessionUser;
- requiredRole: string;
- onLogout: () => void;
-}) {
- return (
-
-
-
Access denied
-
- This page requires the {requiredRole} {' '}
- role. You currently have{' '}
- {user.role} .
-
-
- (window.location.href = '/')}>
- Back to dashboard
-
-
- Sign out
-
-
-
- );
-}
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
deleted file mode 100644
index c40978b..0000000
--- a/src/components/ui/avatar.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import * as AvatarPrimitive from '@radix-ui/react-avatar';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Avatar = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-Avatar.displayName = AvatarPrimitive.Root.displayName;
-
-const AvatarImage = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarImage.displayName = AvatarPrimitive.Image.displayName;
-
-const AvatarFallback = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-
-export { Avatar, AvatarImage, AvatarFallback };
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
deleted file mode 100644
index 8986dd0..0000000
--- a/src/components/ui/badge.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { type VariantProps, cva } from 'class-variance-authority';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const badgeVariants = cva(
- 'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
- {
- variants: {
- variant: {
- default:
- 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
- secondary:
- 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
- destructive:
- 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
- outline: 'text-foreground',
- success:
- 'border-transparent bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
- warning:
- 'border-transparent bg-amber-500/15 text-amber-400 hover:bg-amber-500/20',
- },
- },
- defaultVariants: { variant: 'default' },
- },
-);
-
-export interface BadgeProps
- extends React.HTMLAttributes,
- VariantProps {}
-
-function Badge({ className, variant, ...props }: BadgeProps) {
- return (
-
- );
-}
-
-export { Badge, badgeVariants };
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
deleted file mode 100644
index 4fb6173..0000000
--- a/src/components/ui/button.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-// shadcn-ui (new-york) Button primitive — verbatim from the registry.
-import { Slot } from '@radix-ui/react-slot';
-import { type VariantProps, cva } from 'class-variance-authority';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const buttonVariants = cva(
- 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
- {
- variants: {
- variant: {
- default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
- destructive:
- 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
- outline:
- 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
- secondary:
- 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
- ghost: 'hover:bg-accent hover:text-accent-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
- },
- size: {
- default: 'h-9 px-4 py-2',
- sm: 'h-8 rounded-md px-3 text-xs',
- lg: 'h-10 rounded-md px-8',
- icon: 'h-9 w-9',
- },
- },
- defaultVariants: { variant: 'default', size: 'default' },
- },
-);
-
-export interface ButtonProps
- extends React.ButtonHTMLAttributes,
- VariantProps {
- asChild?: boolean;
-}
-
-const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : 'button';
- return (
-
- );
- },
-);
-Button.displayName = 'Button';
-
-export { Button, buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
deleted file mode 100644
index 3a078c4..0000000
--- a/src/components/ui/card.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Card = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-Card.displayName = 'Card';
-
-const CardHeader = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardHeader.displayName = 'CardHeader';
-
-const CardTitle = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardTitle.displayName = 'CardTitle';
-
-const CardDescription = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardDescription.displayName = 'CardDescription';
-
-const CardContent = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardContent.displayName = 'CardContent';
-
-const CardFooter = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
-
-));
-CardFooter.displayName = 'CardFooter';
-
-export {
- Card,
- CardHeader,
- CardFooter,
- CardTitle,
- CardDescription,
- CardContent,
-};
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
deleted file mode 100644
index ab99df7..0000000
--- a/src/components/ui/dropdown-menu.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
-import { Check, ChevronRight, Circle } from 'lucide-react';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const DropdownMenu = DropdownMenuPrimitive.Root;
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-const DropdownMenuSub = DropdownMenuPrimitive.Sub;
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
-
-const DropdownMenuSubTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, children, ...props }, ref) => (
-
- {children}
-
-
-));
-DropdownMenuSubTrigger.displayName =
- DropdownMenuPrimitive.SubTrigger.displayName;
-
-const DropdownMenuSubContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSubContent.displayName =
- DropdownMenuPrimitive.SubContent.displayName;
-
-const DropdownMenuContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, sideOffset = 4, ...props }, ref) => (
-
-
-
-));
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
-
-const DropdownMenuItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
- svg]:size-4 [&>svg]:shrink-0",
- inset && 'pl-8',
- className,
- )}
- {...props}
- />
-));
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
-
-const DropdownMenuLabel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef & {
- inset?: boolean;
- }
->(({ className, inset, ...props }, ref) => (
-
-));
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
-
-const DropdownMenuSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DropdownMenuSeparator.displayName =
- DropdownMenuPrimitive.Separator.displayName;
-
-const DropdownMenuShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes) => (
-
-);
-DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
-
-export {
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuGroup,
- DropdownMenuPortal,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
- DropdownMenuRadioGroup,
-};
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
deleted file mode 100644
index b9f3cd3..0000000
--- a/src/components/ui/input.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Input = React.forwardRef>(
- ({ className, type, ...props }, ref) => {
- return (
-
- );
- },
-);
-Input.displayName = 'Input';
-
-export { Input };
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
deleted file mode 100644
index a481391..0000000
--- a/src/components/ui/select.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import * as SelectPrimitive from '@radix-ui/react-select';
-import { Check, ChevronDown, ChevronUp } from 'lucide-react';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Select = SelectPrimitive.Root;
-const SelectGroup = SelectPrimitive.Group;
-const SelectValue = SelectPrimitive.Value;
-
-const SelectTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
- span]:line-clamp-1",
- className,
- )}
- {...props}
- >
- {children}
-
-
-
-
-));
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
-
-const SelectScrollUpButton = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-));
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
-
-const SelectScrollDownButton = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-));
-SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
-
-const SelectContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, position = 'popper', ...props }, ref) => (
-
-
-
-
- {children}
-
-
-
-
-));
-SelectContent.displayName = SelectPrimitive.Content.displayName;
-
-const SelectLabel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-SelectLabel.displayName = SelectPrimitive.Label.displayName;
-
-const SelectItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-));
-SelectItem.displayName = SelectPrimitive.Item.displayName;
-
-const SelectSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
-
-export {
- Select,
- SelectGroup,
- SelectValue,
- SelectTrigger,
- SelectContent,
- SelectLabel,
- SelectItem,
- SelectSeparator,
- SelectScrollUpButton,
- SelectScrollDownButton,
-};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
deleted file mode 100644
index b09907f..0000000
--- a/src/components/ui/separator.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import * as SeparatorPrimitive from '@radix-ui/react-separator';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Separator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(
- (
- { className, orientation = 'horizontal', decorative = true, ...props },
- ref,
- ) => (
-
- ),
-);
-Separator.displayName = SeparatorPrimitive.Root.displayName;
-
-export { Separator };
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
deleted file mode 100644
index d86f458..0000000
--- a/src/components/ui/switch.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import * as SwitchPrimitives from '@radix-ui/react-switch';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Switch = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-));
-Switch.displayName = SwitchPrimitives.Root.displayName;
-
-export { Switch };
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
deleted file mode 100644
index cbc9726..0000000
--- a/src/components/ui/tabs.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import * as TabsPrimitive from '@radix-ui/react-tabs';
-import * as React from 'react';
-import { cn } from '@/lib/utils';
-
-const Tabs = TabsPrimitive.Root;
-
-const TabsList = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-TabsList.displayName = TabsPrimitive.List.displayName;
-
-const TabsTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
-
-const TabsContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-TabsContent.displayName = TabsPrimitive.Content.displayName;
-
-export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx
deleted file mode 100644
index 1722a11..0000000
--- a/src/components/users/UsersPage.tsx
+++ /dev/null
@@ -1,303 +0,0 @@
-// 🤠 Heady Users page — React + shadcn rebuild.
-
-import {
- Mail,
- MoreHorizontal,
- Search,
- Shield,
- UserPlus,
- Users as UsersIcon,
-} from 'lucide-react';
-import * as React from 'react';
-import { AppShell, type SessionUser } from '@/components/shell/AppShell';
-import {
- Avatar,
- AvatarFallback,
- AvatarImage,
-} from '@/components/ui/avatar';
-import { Badge } from '@/components/ui/badge';
-import { Button } from '@/components/ui/button';
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui/card';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { Input } from '@/components/ui/input';
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from '@/components/ui/select';
-
-type Role =
- | 'owner'
- | 'admin'
- | 'network_admin'
- | 'it_admin'
- | 'auditor'
- | 'member';
-
-export interface UserRow {
- id: string;
- email: string;
- name?: string;
- preferred_username: string;
- role: Role;
- groups: string[];
- picture?: string;
- last_login?: string;
-}
-
-export interface UsersPageProps {
- users: UserRow[];
-}
-
-export function UsersPage({ users }: UsersPageProps) {
- return (
-
- {(me) => }
-
- );
-}
-
-function UsersBody({ users, me }: UsersPageProps & { me: SessionUser }) {
- const [search, setSearch] = React.useState('');
- const [roleFilter, setRoleFilter] = React.useState('all');
-
- const filtered = React.useMemo(() => {
- const needle = search.trim().toLowerCase();
- return users.filter((u) => {
- if (roleFilter !== 'all' && u.role !== roleFilter) return false;
- if (!needle) return true;
- return (
- u.email.toLowerCase().includes(needle) ||
- (u.name ?? '').toLowerCase().includes(needle) ||
- u.preferred_username.toLowerCase().includes(needle) ||
- u.groups.some((g) => g.toLowerCase().includes(needle))
- );
- });
- }, [users, search, roleFilter]);
-
- const roleBreakdown = React.useMemo(() => {
- const acc: Partial> = {};
- users.forEach((u) => {
- acc[u.role] = (acc[u.role] ?? 0) + 1;
- });
- return acc;
- }, [users]);
-
- return (
-
-
-
- {/* Role summary cards */}
-
- {(
- [
- { role: 'owner', label: 'Owners' },
- { role: 'admin', label: 'Admins' },
- { role: 'network_admin', label: 'Network' },
- { role: 'it_admin', label: 'IT' },
- { role: 'auditor', label: 'Auditors' },
- { role: 'member', label: 'Members' },
- ] as { role: Role; label: string }[]
- ).map(({ role, label }) => (
-
-
-
- {label}
-
-
- {roleBreakdown[role] ?? 0}
-
-
-
- ))}
-
-
- {/* Filter bar */}
-
-
-
-
- setSearch(e.target.value)}
- className="pl-8"
- />
-
-
-
-
-
-
- All roles
- Owner
- Admin
- Network admin
- IT admin
- Auditor
- Member
-
-
-
-
-
- {/* Results card */}
-
-
-
- Directory
-
- {filtered.length} of {users.length} shown
-
-
-
-
-
- {filtered.length === 0 ? (
-
- ) : (
-
- {filtered.map((u) => (
-
- ))}
-
- )}
-
-
-
- );
-}
-
-function UserRowItem({ user, isMe }: { user: UserRow; isMe: boolean }) {
- const initials = (user.name ?? user.preferred_username ?? user.email)
- .split(/[\s.@]/)
- .map((p) => p[0])
- .filter(Boolean)
- .slice(0, 2)
- .join('')
- .toUpperCase();
-
- return (
-
-
- {user.picture && }
- {initials || '?'}
-
-
-
-
{user.name ?? user.preferred_username}
- {isMe && (
-
- you
-
- )}
-
-
-
- {user.email}
-
-
-
-
- {user.groups.slice(0, 2).map((g) => (
-
- {g}
-
- ))}
- {user.groups.length > 2 && (
-
- +{user.groups.length - 2}
-
- )}
-
-
-
- );
-}
-
-function RoleBadge({ role }: { role: Role }) {
- const variants: Record<
- Role,
- { label: string; className: string }
- > = {
- owner: { label: 'Owner', className: 'bg-violet-500/15 text-violet-300' },
- admin: { label: 'Admin', className: 'bg-rose-500/15 text-rose-300' },
- network_admin: {
- label: 'Network',
- className: 'bg-sky-500/15 text-sky-300',
- },
- it_admin: { label: 'IT', className: 'bg-emerald-500/15 text-emerald-300' },
- auditor: { label: 'Auditor', className: 'bg-amber-500/15 text-amber-300' },
- member: { label: 'Member', className: 'bg-muted text-muted-foreground' },
- };
- const v = variants[role];
- return (
-
-
- {v.label}
-
- );
-}
-
-function UserRowMenu({ user }: { user: UserRow }) {
- return (
-
-
-
-
-
-
-
- {user.email}
-
-
- Details
-
- Change role
-
-
- Disable
-
-
-
- );
-}
-
-function EmptyResult() {
- return (
-
-
-
No matching users
-
Try adjusting your search or role filter.
-
- );
-}
diff --git a/src/env.d.ts b/src/env.d.ts
index 2b7470a..e16c13c 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1,11 +1 @@
///
-
-import type { SessionUser } from './lib/auth/session-manager.js';
-
-declare global {
- namespace App {
- interface Locals {
- user?: SessionUser;
- }
- }
-}
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro
index 124079b..3ff6424 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -10,18 +10,11 @@ import '../styles/global.css';
export interface Props {
title: string;
description?: string;
- /**
- * Pages built on the React shadcn shell should set `hideChrome={true}` so
- * the legacy Alpine nav/footer/toast container doesn't double up over the
- * React top nav. Pages still using Alpine keep the default (false).
- */
- hideChrome?: boolean;
}
const {
title,
description = "Strategic VPN management that's actually awesome to use!",
- hideChrome = false,
} = Astro.props;
---
@@ -223,11 +216,7 @@ const {
-
- {hideChrome ? (
-
- ) : (
-
+
@@ -439,7 +428,5 @@ const {
registered before Alpine.js loads. Do not redefine them here — the
non-inline bundled script would overwrite them after Alpine has
already wired up handlers, causing the two copies to drift. -->
-
- )}
\ No newline at end of file
diff --git a/src/lib/auth/oidc-client.ts b/src/lib/auth/oidc-client.ts
index 2fa4a59..8aaad02 100644
--- a/src/lib/auth/oidc-client.ts
+++ b/src/lib/auth/oidc-client.ts
@@ -1,45 +1,20 @@
-// 🔐 OIDC client — thin wrapper around openid-client v6.
-//
-// This module replaces a ~290 LOC hand-rolled OIDC implementation. The
-// upstream library handles discovery, PKCE, state, *and ID token signature
-// verification* (issuer/audience/exp/JWKS rotation) — the latter is the
-// security gap we couldn't address with the hand-rolled code without
-// reinventing JWKS handling.
+// 🔐 OIDC Client - Authentik protocol handler with PKCE support
-import * as client from 'openid-client';
-import type { AuthentikConfig } from '../config/authentik.js';
+import type { AuthentikConfig } from '../config/authentik.ts';
-let _config: client.Configuration | null = null;
-let _configKey: string | null = null;
-
-/**
- * Lazily resolve & cache the openid-client Configuration object.
- * Discovery is done once per (issuer, client_id) pair; switching either
- * invalidates the cache.
- */
-export async function getOidcConfig(
- config: AuthentikConfig,
-): Promise
{
- const key = `${config.issuer}|${config.clientId}`;
- if (_config && _configKey === key) {
- return _config;
- }
-
- const issuer = new URL(config.issuer);
- _config = await client.discovery(
- issuer,
- config.clientId,
- config.clientSecret,
- // undefined → use ClientSecretPost (the default Authentik expects)
- );
- _configKey = key;
- return _config;
+export interface PKCEChallenge {
+ codeVerifier: string;
+ codeChallenge: string;
+ codeChallengeMethod: 'S256';
}
-/** Clear the cached configuration (tests, manual recovery). */
-export function clearOidcCache(): void {
- _config = null;
- _configKey = null;
+export interface TokenResponse {
+ access_token: string;
+ token_type: string;
+ expires_in: number;
+ refresh_token?: string;
+ id_token?: string;
+ scope: string;
}
export interface UserInfo {
@@ -53,5 +28,297 @@ export interface UserInfo {
email_verified?: boolean;
}
-/** Re-export the relevant openid-client functions so endpoints don't import twice. */
-export { client };
+export interface DiscoveryDocument {
+ authorization_endpoint: string;
+ token_endpoint: string;
+ userinfo_endpoint: string;
+ end_session_endpoint?: string;
+ scopes_supported: string[];
+ response_types_supported: string[];
+ code_challenge_methods_supported: string[];
+}
+
+/**
+ * Authentik OIDC Client with PKCE support and auto-discovery
+ */
+export class AuthentikOIDCClient {
+ private config: AuthentikConfig;
+ private discoveryCache: DiscoveryDocument | null = null;
+
+ constructor(config: AuthentikConfig) {
+ // Validate configuration
+ if (!config.clientId || config.clientId.length < 3) {
+ throw new Error(
+ 'Invalid OIDC configuration: clientId must be at least 3 characters',
+ );
+ }
+ if (!config.clientSecret || config.clientSecret.length < 10) {
+ throw new Error(
+ 'Invalid OIDC configuration: clientSecret must be at least 10 characters',
+ );
+ }
+ try {
+ new URL(config.issuer);
+ } catch {
+ throw new Error('Invalid issuer URL');
+ }
+
+ this.config = config;
+ }
+
+ /**
+ * Discover OIDC endpoints from well-known configuration
+ */
+ async discover(): Promise {
+ if (this.discoveryCache) {
+ return this.discoveryCache;
+ }
+
+ try {
+ const response = await fetch(this.config.enhanced.wellKnownUrl);
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch OIDC discovery document: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const discovery = await response.json();
+
+ // Validate required endpoints
+ if (
+ !discovery.authorization_endpoint ||
+ !discovery.token_endpoint ||
+ !discovery.userinfo_endpoint
+ ) {
+ throw new Error(
+ 'Invalid discovery document: missing required endpoints',
+ );
+ }
+
+ this.discoveryCache = discovery;
+ return discovery;
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes('discovery document')
+ ) {
+ throw error;
+ }
+ throw new Error(`Failed to fetch OIDC discovery document: ${error}`);
+ }
+ }
+
+ /**
+ * Generate PKCE code verifier and challenge
+ */
+ async generatePKCE(): Promise {
+ // Generate code verifier (43-128 characters, URL-safe)
+ const array = new Uint8Array(32);
+ crypto.getRandomValues(array);
+ const codeVerifier = btoa(String.fromCharCode(...array))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+
+ // Generate code challenge (SHA256 hash of verifier, base64url encoded)
+ const encoder = new TextEncoder();
+ const data = encoder.encode(codeVerifier);
+ const digest = await crypto.subtle.digest('SHA-256', data);
+ const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+
+ return {
+ codeVerifier,
+ codeChallenge,
+ codeChallengeMethod: 'S256',
+ };
+ }
+
+ /**
+ * Build authorization URL with PKCE parameters
+ */
+ buildAuthorizationUrl(state: string, pkce: PKCEChallenge): string {
+ const authUrl = new URL(this.config.enhanced.authorizationEndpoint);
+
+ authUrl.searchParams.set('client_id', this.config.clientId);
+ authUrl.searchParams.set('response_type', 'code');
+ authUrl.searchParams.set('redirect_uri', this.config.redirectUri);
+ authUrl.searchParams.set('scope', this.config.scopes.join(' '));
+ authUrl.searchParams.set('state', state);
+ authUrl.searchParams.set('code_challenge', pkce.codeChallenge);
+ authUrl.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
+
+ return authUrl.toString();
+ }
+
+ /**
+ * Exchange authorization code for tokens using PKCE
+ */
+ async exchangeCodeForTokens(
+ code: string,
+ codeVerifier: string,
+ ): Promise {
+ // Ensure we have discovery document
+ await this.getDiscoveryDocument();
+
+ const tokenUrl = this.config.enhanced.tokenEndpoint;
+
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ redirect_uri: this.config.redirectUri,
+ client_id: this.config.clientId,
+ client_secret: this.config.clientSecret,
+ code_verifier: codeVerifier,
+ });
+
+ try {
+ const response = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json',
+ },
+ body: body.toString(),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ const errorMsg = errorData.error || `HTTP ${response.status}`;
+ const errorDesc = errorData.error_description || response.statusText;
+ throw new Error(`Token exchange failed: ${errorMsg} - ${errorDesc}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes('Token exchange failed')
+ ) {
+ throw error;
+ }
+ throw new Error(`Token exchange failed: ${error}`);
+ }
+ }
+
+ /**
+ * Fetch user information using access token
+ */
+ async fetchUserInfo(accessToken: string): Promise {
+ // Ensure we have discovery document
+ await this.getDiscoveryDocument();
+
+ const userInfoUrl = this.config.enhanced.userInfoEndpoint;
+
+ try {
+ const response = await fetch(userInfoUrl, {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
+ Accept: 'application/json',
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch user info: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const userInfo = await response.json();
+
+ // Ensure groups is always an array
+ if (!Array.isArray(userInfo.groups)) {
+ userInfo.groups = [];
+ }
+
+ return userInfo;
+ } catch (error) {
+ if (
+ error instanceof Error &&
+ error.message.includes('Failed to fetch user info')
+ ) {
+ throw error;
+ }
+ throw new Error(`Failed to fetch user info: ${error}`);
+ }
+ }
+
+ /**
+ * Build logout URL for OIDC end session endpoint.
+ * Prefers the discovery document's end_session_endpoint (which Authentik
+ * publishes correctly) and falls back to the convention-built URL only if
+ * discovery hasn't been fetched yet or omitted it.
+ */
+ buildLogoutUrl(postLogoutRedirectUri?: string): string {
+ const endpoint =
+ this.discoveryCache?.end_session_endpoint ??
+ this.config.enhanced.endSessionEndpoint;
+
+ const logoutUrl = new URL(endpoint);
+
+ if (postLogoutRedirectUri) {
+ logoutUrl.searchParams.set(
+ 'post_logout_redirect_uri',
+ postLogoutRedirectUri,
+ );
+ }
+
+ return logoutUrl.toString();
+ }
+
+ /**
+ * Generate cryptographically secure state parameter
+ */
+ generateState(): string {
+ const array = new Uint8Array(16);
+ crypto.getRandomValues(array);
+ return btoa(String.fromCharCode(...array))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '');
+ }
+
+ /**
+ * Validate state parameter format
+ */
+ validateState(state: string): boolean {
+ // State should be URL-safe base64 without spaces or special chars
+ const validPattern = /^[A-Za-z0-9\-_]+$/;
+ return state.length > 0 && validPattern.test(state);
+ }
+
+ /**
+ * Get discovery document (internal helper that handles caching and errors)
+ */
+ private async getDiscoveryDocument(): Promise {
+ if (!this.discoveryCache) {
+ try {
+ await this.discover();
+ } catch (error) {
+ console.error('Discovery document error:', error);
+ throw error;
+ }
+ }
+
+ if (!this.discoveryCache) {
+ throw new Error('Failed to load discovery document');
+ }
+
+ // Validate required endpoints
+ if (!this.discoveryCache.authorization_endpoint) {
+ throw new Error('Missing required endpoint: authorization_endpoint');
+ }
+
+ return this.discoveryCache;
+ }
+
+ /**
+ * Clear cached discovery document (useful for testing or recovery)
+ */
+ clearCache(): void {
+ this.discoveryCache = null;
+ }
+}
diff --git a/src/lib/auth/oidc-state.ts b/src/lib/auth/oidc-state.ts
index 91c36d3..d40127d 100644
--- a/src/lib/auth/oidc-state.ts
+++ b/src/lib/auth/oidc-state.ts
@@ -21,8 +21,6 @@ const COOKIE_MAX_AGE_SECONDS = 10 * 60; // 10 minutes — matches OAuth state TT
interface OidcStatePayload {
state: string;
codeVerifier: string;
- /** Optional nonce — required for ID token replay protection. */
- nonce?: string;
}
/**
@@ -42,7 +40,6 @@ export async function storeOidcState(
const token = await new SignJWT({
state: payload.state,
cv: payload.codeVerifier,
- ...(payload.nonce ? { n: payload.nonce } : {}),
})
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
@@ -83,16 +80,11 @@ export async function consumeOidcState(
const state = payload.state;
const codeVerifier = payload.cv;
- const nonce = payload.n;
if (typeof state !== 'string' || typeof codeVerifier !== 'string') {
return null;
}
- return {
- state,
- codeVerifier,
- ...(typeof nonce === 'string' ? { nonce } : {}),
- };
+ return { state, codeVerifier };
} catch (error) {
console.warn('OIDC state cookie verification failed:', error);
return null;
diff --git a/src/lib/auth/session-manager.ts b/src/lib/auth/session-manager.ts
index 86496e0..32db942 100644
--- a/src/lib/auth/session-manager.ts
+++ b/src/lib/auth/session-manager.ts
@@ -1,13 +1,6 @@
-// 🔐 Session Manager - Secure HTTP-only cookie session management.
-//
-// Storage is pluggable via the SessionStore interface. Auto-selects Redis
-// when REDIS_URL is set, falls back to an in-memory Map otherwise. The
-// in-memory store is fine for unit tests and single-node dev but does NOT
-// survive Vite HMR / process restart, and is invisible to other workers in
-// a cluster — production deployments must point REDIS_URL at a real Redis.
+// 🔐 Session Manager - Secure HTTP-only cookie session management
import type { APIContext } from 'astro';
-import Redis from 'ioredis';
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
import { getSessionConfig } from '../config/authentik.js';
@@ -58,241 +51,54 @@ export interface SessionCleanupResult {
error_details?: string[];
}
-/** Internal session record. */
-interface SessionData {
- user: SessionUser;
- created_at: string;
- last_activity: string;
- expires_at: string;
- ip_address: string;
-}
-
-/* ────────────────────────────────────────────── Session storage ──── */
-
/**
- * Storage backend interface. All ops are async so Redis implementations
- * are natural; in-memory wraps Map operations in Promise.resolve.
- */
-interface SessionStore {
- get(sessionId: string): Promise;
- /** ttlSeconds is the absolute expiration delta; the store should honor it. */
- set(sessionId: string, data: SessionData, ttlSeconds: number): Promise;
- delete(sessionId: string): Promise;
- /** Size hint for monitoring. May be approximate (Redis SCAN sampled). */
- size(): Promise;
- /**
- * Sweep expired entries. Redis handles TTL automatically so this is a
- * no-op there; in-memory implementations need to walk the Map.
- */
- cleanup(now: Date): Promise;
- /** Shut down any background work (timers, Redis client). */
- dispose(): Promise;
-}
-
-/** In-memory store. Used for tests and dev when REDIS_URL isn't set. */
-class InMemorySessionStore implements SessionStore {
- private map = new Map();
-
- async get(sessionId: string): Promise {
- return this.map.get(sessionId) ?? null;
- }
-
- async set(
- sessionId: string,
- data: SessionData,
- _ttlSeconds: number,
- ): Promise {
- // TTL is enforced via the expires_at field in the record; cleanup()
- // walks the Map periodically.
- this.map.set(sessionId, data);
- }
-
- async delete(sessionId: string): Promise {
- return this.map.delete(sessionId);
- }
-
- async size(): Promise {
- return this.map.size;
- }
-
- async cleanup(now: Date): Promise {
- let cleaned = 0;
- const errorDetails: string[] = [];
- for (const [id, data] of this.map.entries()) {
- if (now > new Date(data.expires_at)) {
- try {
- this.map.delete(id);
- cleaned++;
- } catch (err) {
- errorDetails.push(`Failed to delete ${id.slice(0, 8)}: ${err}`);
- }
- }
- }
- return {
- cleaned,
- errors: errorDetails.length,
- error_details: errorDetails.length ? errorDetails : undefined,
- };
- }
-
- async dispose(): Promise {
- this.map.clear();
- }
-}
-
-/** Redis store. Uses one key per session with native EXPIRE. */
-class RedisSessionStore implements SessionStore {
- private client: Redis;
- private prefix = 'heady:sess:';
-
- constructor(url: string) {
- this.client = new Redis(url, {
- // Don't keep reconnecting forever on misconfigured URLs in dev —
- // fail loud after a few attempts so the operator notices.
- maxRetriesPerRequest: 3,
- lazyConnect: false,
- });
- this.client.on('error', (err) => {
- console.error('Redis session store error:', err.message);
- });
- this.client.on('connect', () => {
- console.log(`✓ Redis session store connected at ${url}`);
- });
- }
-
- private key(sessionId: string): string {
- return `${this.prefix}${sessionId}`;
- }
-
- async get(sessionId: string): Promise {
- const raw = await this.client.get(this.key(sessionId));
- if (!raw) return null;
- try {
- return JSON.parse(raw) as SessionData;
- } catch {
- // Corrupted value — best to drop it.
- await this.client.del(this.key(sessionId));
- return null;
- }
- }
-
- async set(
- sessionId: string,
- data: SessionData,
- ttlSeconds: number,
- ): Promise {
- // SET ... EX atomically writes and sets the expiration so we
- // don't have to clean up by hand.
- await this.client.set(
- this.key(sessionId),
- JSON.stringify(data),
- 'EX',
- ttlSeconds,
- );
- }
-
- async delete(sessionId: string): Promise {
- const count = await this.client.del(this.key(sessionId));
- return count > 0;
- }
-
- async size(): Promise {
- // SCAN to avoid blocking on KEYS in production. We accept that this
- // is approximate under concurrent writes.
- let cursor = '0';
- let count = 0;
- do {
- const [next, batch] = await this.client.scan(
- cursor,
- 'MATCH',
- `${this.prefix}*`,
- 'COUNT',
- 100,
- );
- cursor = next;
- count += batch.length;
- } while (cursor !== '0');
- return count;
- }
-
- async cleanup(_now: Date): Promise {
- // Redis TTL handles expiration natively — nothing for us to do here.
- return { cleaned: 0, errors: 0 };
- }
-
- async dispose(): Promise {
- this.client.disconnect();
- }
-}
-
-/* ────────────────────────────────────────── HeadySessionManager ──── */
-
-/**
- * Secure session manager with HTTP-only signed cookies.
- * Sessions are stored in a SessionStore (Redis in production, in-memory
- * for dev/tests). Cookies carry a signed JWT containing the session ID;
- * the server looks the rest of the record up in the store.
+ * Secure session manager with HTTP-only cookies and in-memory storage
+ * In production, this should be backed by a persistent store (Redis, Database)
*/
export class HeadySessionManager {
+ private sessions = new Map();
private config = getSessionConfig();
- private store: SessionStore;
private cleanupTimer: ReturnType | null = null;
- private storeKind: 'redis' | 'memory';
- constructor(store?: SessionStore) {
- if (store) {
- this.store = store;
- this.storeKind = store instanceof RedisSessionStore ? 'redis' : 'memory';
- } else {
- const redisUrl = process.env.REDIS_URL;
- if (redisUrl) {
- this.store = new RedisSessionStore(redisUrl);
- this.storeKind = 'redis';
- } else {
- this.store = new InMemorySessionStore();
- this.storeKind = 'memory';
- console.warn(
- '⚠️ Using in-memory session store. Sessions WILL be lost on restart. Set REDIS_URL for production.',
- );
- }
- }
-
- // Only the in-memory store needs a sweeper. Redis handles TTL itself.
- if (this.storeKind === 'memory') {
- this.cleanupTimer = setInterval(
- () => {
- this.store.cleanup(new Date()).catch(console.error);
- },
- 5 * 60 * 1000,
- );
- if (
- typeof this.cleanupTimer === 'object' &&
- this.cleanupTimer &&
- 'unref' in this.cleanupTimer
- ) {
- (this.cleanupTimer as { unref: () => void }).unref();
- }
+ constructor() {
+ // Cleanup expired sessions every 5 minutes.
+ // unref() lets the Node process exit even if the timer is still
+ // scheduled — without this, tests and short-lived scripts hang.
+ this.cleanupTimer = setInterval(
+ () => {
+ this.cleanupExpiredSessions().catch(console.error);
+ },
+ 5 * 60 * 1000,
+ );
+ if (typeof this.cleanupTimer === 'object' && this.cleanupTimer && 'unref' in this.cleanupTimer) {
+ (this.cleanupTimer as { unref: () => void }).unref();
}
}
- /** Stop the cleanup timer and release the store. */
- async dispose(): Promise {
+ /**
+ * Stop the cleanup interval. Call this when disposing the manager
+ * (e.g. in tests or on graceful shutdown).
+ */
+ dispose(): void {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
- await this.store.dispose();
}
- /** Create a new session, write the cookie, return the result. */
+ /**
+ * Create a new session with HTTP-only secure cookie
+ */
async createSession(
context: APIContext,
user: SessionUser,
): Promise {
+ // Generate cryptographically secure session ID
const sessionId = this.generateSessionId();
const now = new Date();
const expiresAt = new Date(now.getTime() + this.config.lifetime);
+ // Create session data
const sessionData: SessionData = {
user,
created_at: now.toISOString(),
@@ -301,13 +107,13 @@ export class HeadySessionManager {
ip_address: context.clientAddress,
};
- await this.store.set(
- sessionId,
- sessionData,
- Math.floor(this.config.lifetime / 1000),
- );
+ // Store session in memory (in production, use persistent store)
+ this.sessions.set(sessionId, sessionData);
+ // Create signed session token (HMAC-SHA256 JWT)
const sessionToken = await this.signSessionToken(sessionId);
+
+ // Set HTTP-only secure cookie
context.cookies.set(this.config.cookieName, sessionToken, {
httpOnly: this.config.httpOnly,
secure: this.config.secure,
@@ -317,7 +123,7 @@ export class HeadySessionManager {
});
console.log(
- `✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...) [store=${this.storeKind}]`,
+ `✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...)`,
);
return {
@@ -335,38 +141,38 @@ export class HeadySessionManager {
};
}
- /** Validate the request's session cookie. */
- async validateSession(
- context: APIContext,
- ): Promise {
+ /**
+ * Validate existing session from cookie
+ */
+ async validateSession(context: APIContext): Promise {
const sessionCookie = context.cookies.get(this.config.cookieName);
if (!sessionCookie) {
return { valid: false, user: null, reason: 'missing_cookie' };
}
try {
+ // Verify signed session token to get session ID
const sessionId = await this.verifySessionToken(sessionCookie.value);
- const storedSession = await this.store.get(sessionId);
+
+ // Check if session exists in store
+ const storedSession = this.sessions.get(sessionId);
if (!storedSession) {
return { valid: false, user: null, reason: 'session_not_found' };
}
+ // Check if session is expired
const now = new Date();
const expiresAt = new Date(storedSession.expires_at);
if (now > expiresAt) {
- await this.store.delete(sessionId);
+ // Clean up expired session
+ this.sessions.delete(sessionId);
return { valid: false, user: null, reason: 'expired' };
}
- // Bump last_activity and write back. Resets the TTL too so an
- // active user doesn't get logged out on the sliding window
- // expiration of the configured lifetime.
+ // Update last activity
storedSession.last_activity = now.toISOString();
- await this.store.set(
- sessionId,
- storedSession,
- Math.max(1, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)),
- );
+
+ console.log(`✓ Session validated for ${storedSession.user.email}`);
return {
valid: true,
@@ -385,7 +191,9 @@ export class HeadySessionManager {
}
}
- /** Extend session lifetime + reissue cookie. */
+ /**
+ * Refresh session expiration time
+ */
async refreshSession(context: APIContext): Promise {
const validation = await this.validateSession(context);
if (!validation.valid || !validation.user) {
@@ -399,7 +207,7 @@ export class HeadySessionManager {
}
const sessionId = validation.user.session.session_id;
- const storedSession = await this.store.get(sessionId);
+ const storedSession = this.sessions.get(sessionId);
if (!storedSession) {
return {
session_id: sessionId,
@@ -410,16 +218,14 @@ export class HeadySessionManager {
};
}
+ // Extend session lifetime
const now = new Date();
const newExpiresAt = new Date(now.getTime() + this.config.lifetime);
+
storedSession.expires_at = newExpiresAt.toISOString();
storedSession.last_activity = now.toISOString();
- await this.store.set(
- sessionId,
- storedSession,
- Math.floor(this.config.lifetime / 1000),
- );
+ // Update cookie with re-signed token
const sessionToken = await this.signSessionToken(sessionId);
context.cookies.set(this.config.cookieName, sessionToken, {
httpOnly: this.config.httpOnly,
@@ -437,36 +243,82 @@ export class HeadySessionManager {
};
}
- /** Destroy session and clear the cookie. */
- async destroySession(
- context: APIContext,
- ): Promise {
+ /**
+ * Destroy session and clear cookie
+ */
+ async destroySession(context: APIContext): Promise {
const sessionCookie = context.cookies.get(this.config.cookieName);
if (!sessionCookie) {
return { destroyed: false, reason: 'session_not_found' };
}
try {
+ // Verify signed session token to get session ID
const sessionId = await this.verifySessionToken(sessionCookie.value);
- const existed = await this.store.delete(sessionId);
+
+ // Remove from store
+ const existed = this.sessions.delete(sessionId);
+
+ // Clear cookie
context.cookies.delete(this.config.cookieName);
+
console.log(`✓ Session destroyed: ${sessionId.slice(0, 8)}...`);
+
return { destroyed: existed };
- } catch {
+ } catch (error) {
+ // Clear cookie anyway
context.cookies.delete(this.config.cookieName);
return { destroyed: false, reason: 'malformed_session' };
}
}
- /** Sweep expired sessions. Backend-dependent semantics. */
+ /**
+ * Clean up expired sessions
+ */
async cleanupExpiredSessions(): Promise {
- return this.store.cleanup(new Date());
- }
-
- async getActiveCount(): Promise {
- return this.store.size();
+ const now = new Date();
+ let cleaned = 0;
+ let errors = 0;
+ const errorDetails: string[] = [];
+
+ try {
+ for (const [sessionId, sessionData] of this.sessions.entries()) {
+ const expiresAt = new Date(sessionData.expires_at);
+ if (now > expiresAt) {
+ try {
+ this.sessions.delete(sessionId);
+ cleaned++;
+ } catch (error) {
+ errors++;
+ errorDetails.push(
+ `Failed to delete session ${sessionId.slice(0, 8)}: ${error}`,
+ );
+ }
+ }
+ }
+
+ if (cleaned > 0) {
+ console.log(`✓ Cleaned up ${cleaned} expired sessions`);
+ }
+
+ return {
+ cleaned,
+ errors,
+ error_details: errorDetails.length > 0 ? errorDetails : undefined,
+ };
+ } catch (error) {
+ console.error('Session cleanup error:', error);
+ return {
+ cleaned,
+ errors: errors + 1,
+ error_details: [`Cleanup failed: ${error}`],
+ };
+ }
}
+ /**
+ * Generate cryptographically secure session ID
+ */
private generateSessionId(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
@@ -476,6 +328,10 @@ export class HeadySessionManager {
);
}
+ /**
+ * Sign session token using HMAC-SHA256 JWT
+ * Provides cryptographic integrity (tamper-evident) and configurable expiration
+ */
private async signSessionToken(sessionId: string): Promise {
const secret = new TextEncoder().encode(this.config.secret);
const expirationSeconds = Math.floor(
@@ -489,18 +345,27 @@ export class HeadySessionManager {
.sign(secret);
}
+ /**
+ * Verify signed session token using HMAC-SHA256 JWT
+ * Uses constant-time comparison internally to prevent timing attacks
+ * Honors the configured session lifetime via JWT exp claim
+ */
private async verifySessionToken(token: string): Promise {
const secret = new TextEncoder().encode(this.config.secret);
+
try {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
});
+
const sessionId = payload.sid;
if (typeof sessionId !== 'string' || !sessionId.startsWith('sess_')) {
throw new Error('Invalid session token payload');
}
+
return sessionId;
} catch (error) {
+ // jose throws specific error types for expired/invalid tokens
if (error instanceof joseErrors.JWTExpired) {
throw new Error('Session token expired');
}
@@ -514,11 +379,21 @@ export class HeadySessionManager {
}
}
-/* ─────────────────────────────────────────────────── singletons ──── */
+/**
+ * Internal session data structure
+ */
+interface SessionData {
+ user: SessionUser;
+ created_at: string;
+ last_activity: string;
+ expires_at: string;
+ ip_address: string;
+}
/**
- * Shared singleton across API routes. Lazy so import-time config errors
- * don't crash unrelated modules.
+ * Shared singleton session manager
+ * All API routes must use this instance to share the same sessions Map.
+ * Lazily instantiated so import-time config errors don't crash routes.
*/
let _sessionManager: HeadySessionManager | null = null;
@@ -530,13 +405,10 @@ export function getSessionManager(): HeadySessionManager {
}
/**
- * Current active session count. Async now because Redis lookups are.
- * The legacy sync getActiveSessionCount() callers (status endpoint) need
- * to be updated; we keep the old export name pointed at a synchronous
- * fallback that returns 0 to avoid a breaking change at import-time.
+ * Get current active session count from the shared singleton
*/
export function getActiveSessionCount(): number {
- // Backwards-compatible synchronous accessor. New callers should prefer
- // getSessionManager().getActiveCount() which returns a Promise.
- return 0;
+ if (!_sessionManager) return 0;
+ return (_sessionManager as unknown as { sessions: Map })
+ .sessions.size;
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
deleted file mode 100644
index 220fa4d..0000000
--- a/src/lib/utils.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { type ClassValue, clsx } from 'clsx';
-import { twMerge } from 'tailwind-merge';
-
-/**
- * shadcn-ui's classname helper: merges Tailwind classes intelligently
- * (later classes override earlier ones for the same property).
- */
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs));
-}
diff --git a/src/middleware.ts b/src/middleware.ts
deleted file mode 100644
index 6306719..0000000
--- a/src/middleware.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { defineMiddleware } from 'astro:middleware';
-import { getSessionManager } from './lib/auth/session-manager.js';
-
-// Public routes — must be reachable WITHOUT a valid session so the auth
-// flow itself can happen. Everything else requires authentication.
-// The dashboard at `/` is intentionally NOT public: it renders real
-// machine + session data. Add explicit landing/marketing paths here if
-// one is added later.
-const PUBLIC_API_PREFIX = '/api/auth/';
-const PUBLIC_PAGE_PATHS = new Set([
- '/login',
- '/login/',
-]);
-const PUBLIC_ASSET_PREFIXES = ['/assets/', '/_astro/'];
-const PUBLIC_ASSET_FILES = new Set([
- '/favicon.svg',
- '/favicon.ico',
- '/robots.txt',
-]);
-
-function isPublicPath(pathname: string): boolean {
- if (pathname.startsWith(PUBLIC_API_PREFIX)) return true;
- if (PUBLIC_PAGE_PATHS.has(pathname)) return true;
- if (PUBLIC_ASSET_FILES.has(pathname)) return true;
- for (const prefix of PUBLIC_ASSET_PREFIXES) {
- if (pathname.startsWith(prefix)) return true;
- }
- return false;
-}
-
-export const onRequest = defineMiddleware(async (context, next) => {
- const { pathname } = context.url;
-
- if (isPublicPath(pathname)) {
- return next();
- }
-
- const sessionMgr = getSessionManager();
- const result = await sessionMgr.validateSession(context);
-
- if (result.valid && result.user) {
- context.locals.user = result.user;
- return next();
- }
-
- // Unauthenticated: JSON for /api/*, redirect for pages.
- if (pathname.startsWith('/api/')) {
- return new Response(
- JSON.stringify({
- authenticated: false,
- error: 'authentication_required',
- reason: result.reason ?? 'missing_cookie',
- login_url: '/api/auth/login',
- }),
- {
- status: 401,
- headers: { 'Content-Type': 'application/json' },
- },
- );
- }
-
- const loginUrl = new URL('/api/auth/login', context.url);
- loginUrl.searchParams.set('return_to', pathname + context.url.search);
- return context.redirect(loginUrl.pathname + loginUrl.search, 302);
-});
diff --git a/src/pages/acls.astro b/src/pages/acls.astro
index a2340ff..9ae10f1 100644
--- a/src/pages/acls.astro
+++ b/src/pages/acls.astro
@@ -10,9 +10,6 @@ import ACLEditor from '../components/acls/ACLEditor';
import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
import Layout from '../layouts/Layout.astro';
-// SSR: gated by role (network_admin), must run auth middleware every request.
-export const prerender = false;
-
// 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 = {
diff --git a/src/pages/api/acls.ts b/src/pages/api/acls.ts
index daef450..45407dc 100644
--- a/src/pages/api/acls.ts
+++ b/src/pages/api/acls.ts
@@ -1,9 +1,6 @@
// Heady ACL API - Alpine.js/Astro ACL Management Endpoint 🤠
import type { APIRoute } from 'astro';
-// Force SSR — auth middleware must run on every request.
-export const prerender = false;
-
// Mock ACL data storage (in production, this would interface with Headscale API)
let mockAclPolicy = `{
// ACL Policy for Heady Network
diff --git a/src/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts
index 9348ebb..fb035f5 100644
--- a/src/pages/api/auth/callback.ts
+++ b/src/pages/api/auth/callback.ts
@@ -1,17 +1,12 @@
-// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication.
-//
-// openid-client.authorizationCodeGrant() does all the heavy lifting:
-// • State validation (CSRF)
-// • PKCE verification
-// • ID token signature verification against the IdP's JWKS
-// • iss / aud / exp / nonce checks
-// • Returns a TokenSet with verified claims
-//
-// We then fetch userinfo (with subject pinning to prevent token substitution)
-// and create the Heady session.
+// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication
+
+/**
+ * Handles OIDC callback from Authentik
+ * Exchanges authorization code for tokens, creates session, and redirects to dashboard
+ */
import type { APIRoute } from 'astro';
-import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
+import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { consumeOidcState } from '../../../lib/auth/oidc-state.js';
import { mapAuthentikGroups } from '../../../lib/auth/role-mapper.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
@@ -25,75 +20,77 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Callback received');
- // Authentik-side error.
+ // Extract parameters from callback
+ const code = url.searchParams.get('code');
+ const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
+
+ // Handle Authentik error responses.
+ // Don't reflect Authentik's error_description into the URL — it's
+ // attacker-controlled if Authentik is compromised/MITM'd.
if (error) {
console.error(`❌ Authentik returned error: ${error}`);
return redirect(`/login?error=${encodeURIComponent(error)}`);
}
- // Pull the PKCE verifier + state + nonce we stashed during /login.
+ // Validate required parameters
+ if (!code || !state) {
+ console.error('❌ Missing required parameters in callback');
+ return redirect('/login?error=invalid_callback');
+ }
+
+ console.log(`✓ Callback parameters received - State: ${state}`);
+
+ // Retrieve and verify the signed state cookie. The cookie carries the
+ // PKCE code_verifier plus the state nonce we set during /api/auth/login.
+ // This works across any number of workers/replicas because the state
+ // rides with the browser, not the server.
const stateData = await consumeOidcState(context);
if (!stateData) {
console.error('❌ Missing or invalid state cookie');
return redirect('/login?error=invalid_state');
}
- console.log('✓ State cookie retrieved');
-
- const config = loadAuthentikConfig();
- const oidcConfig = await getOidcConfig(config);
-
- // Hand the full current URL (with code + state query params) to
- // openid-client. It will:
- // - Compare query.state to expectedState
- // - Use pkceCodeVerifier to prove possession of the original request
- // - Verify the returned ID token's signature against the IdP's JWKS
- // - Check iss / aud / exp / nonce claims
- console.log(' → Exchanging code for tokens (with ID token verification)...');
- const tokens = await client.authorizationCodeGrant(oidcConfig, url, {
- pkceCodeVerifier: stateData.codeVerifier,
- expectedState: stateData.state,
- ...(stateData.nonce ? { expectedNonce: stateData.nonce } : {}),
- });
-
- const idTokenClaims = tokens.claims();
- if (!idTokenClaims) {
- throw new Error('Token response missing verified ID token claims');
+ // Compare the state in the cookie with the state from the IdP redirect.
+ // They must match — this is the CSRF defense for the OAuth dance.
+ if (stateData.state !== state) {
+ console.error('❌ State mismatch (CSRF check failed)');
+ return redirect('/login?error=invalid_state');
}
+
+ console.log(`✓ State validated and PKCE verifier retrieved`);
+
+ // Load Authentik configuration
+ const config = loadAuthentikConfig();
+
+ // Create OIDC client
+ const oidcClient = new AuthentikOIDCClient(config);
+
+ // Exchange authorization code for tokens
+ console.log(' → Exchanging code for tokens...');
+ const tokens = await oidcClient.exchangeCodeForTokens(
+ code,
+ stateData.codeVerifier,
+ );
console.log(
- `✓ Tokens received and ID token verified (sub: ${idTokenClaims.sub})`,
+ `✓ Tokens received (access token: ${tokens.access_token.substring(0, 20)}...)`,
);
- // Fetch userinfo. Pinning expectedSubject prevents token-substitution
- // (returning userinfo for a different user than the ID token's sub).
- console.log(' → Fetching userinfo...');
- const userInfoRaw = await client.fetchUserInfo(
- oidcConfig,
- tokens.access_token,
- idTokenClaims.sub,
- );
-
- const userInfo = {
- sub: userInfoRaw.sub,
- email: (userInfoRaw.email as string) ?? '',
- name: (userInfoRaw.name as string) ?? '',
- picture: userInfoRaw.picture as string | undefined,
- groups: Array.isArray(userInfoRaw.groups)
- ? (userInfoRaw.groups as string[])
- : [],
- };
+ // Fetch user information from Authentik
+ console.log(' → Fetching user information...');
+ const userInfo = await oidcClient.fetchUserInfo(tokens.access_token);
console.log(
`✓ User info received: ${userInfo.email} (${userInfo.groups.length} groups)`,
);
- // Map Authentik groups → Heady role.
+ // Map Authentik groups to Heady role
const roleMapping = mapAuthentikGroups(userInfo.groups);
console.log(`✓ Role mapping completed:`);
console.log(` User groups: ${userInfo.groups.join(', ') || 'none'}`);
console.log(` Mapped role: ${roleMapping.role} (${roleMapping.method})`);
console.log(` Matched group: ${roleMapping.matchedGroup || 'none'}`);
+ // Create user data for session
const sessionUser = {
email: userInfo.email,
name: userInfo.name,
@@ -102,9 +99,14 @@ export const GET: APIRoute = async (context) => {
picture: userInfo.picture,
groups: userInfo.groups,
capabilities: roleMapping.role_mapping.capabilities,
- session: { session_id: '', expires_at: '', last_activity: '' },
+ session: {
+ session_id: '', // Will be filled by session manager
+ expires_at: '', // Will be filled by session manager
+ last_activity: '', // Will be filled by session manager
+ },
};
+ // Create secure session
console.log(' → Creating session...');
const sessionMgr = getSessionManager();
const sessionResult = await sessionMgr.createSession(context, sessionUser);
@@ -112,10 +114,19 @@ export const GET: APIRoute = async (context) => {
console.log(
`✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`,
);
- console.log(`🎉 Authentication successful for ${userInfo.email}`);
+ console.log(` Expires: ${sessionResult.expires_at}`);
+ console.log(`🎉 Authentication successful for ${userInfo.email}`);
+ console.log(` Role: ${roleMapping.role}`);
+ console.log(` Groups: ${userInfo.groups.join(', ') || 'none'}`);
+ console.log(
+ ` Session expires: ${new Date(sessionResult.expires_at).toLocaleString()}`,
+ );
+
+ // Redirect to dashboard (session cookie was set by session manager)
return redirect('/', 302);
} catch (error) {
+ // Log full error details server-side for diagnostics.
console.error('❌ OIDC callback error:', error);
if (error instanceof Error) {
console.error(` Error type: ${error.constructor.name}`);
@@ -127,19 +138,16 @@ export const GET: APIRoute = async (context) => {
}
}
- // Map known error shapes to safe codes — never reflect error messages.
+ // Redirect with a SAFE error code only — never reflect raw error.message
+ // into the URL. Doing so leaks internal details (client_id, hostnames,
+ // stack hints) to the browser URL bar, history, access logs, and any
+ // Referer header on subsequent navigations.
let errorCode = 'authentication_failed';
if (error instanceof Error) {
const msg = error.message.toLowerCase();
- if (msg.includes('state')) errorCode = 'invalid_state';
- else if (msg.includes('nonce')) errorCode = 'invalid_nonce';
- else if (msg.includes('pkce') || msg.includes('code_verifier'))
- errorCode = 'invalid_pkce';
- else if (msg.includes('signature') || msg.includes('jws'))
- errorCode = 'invalid_id_token';
+ if (msg.includes('token exchange')) errorCode = 'token_exchange_failed';
+ else if (msg.includes('user info')) errorCode = 'user_info_failed';
else if (msg.includes('discovery')) errorCode = 'discovery_failed';
- else if (msg.includes('userinfo')) errorCode = 'user_info_failed';
- else if (msg.includes('token')) errorCode = 'token_exchange_failed';
}
return redirect(`/login?error=callback_failed&code=${errorCode}`);
diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts
index c342272..b0300b8 100644
--- a/src/pages/api/auth/login.ts
+++ b/src/pages/api/auth/login.ts
@@ -1,10 +1,12 @@
-// 🤠 Heady OIDC Login Endpoint - Initiate Authentik Authentication.
-//
-// Generates PKCE + state + nonce, stashes them in a signed cookie, and
-// redirects the user to the OIDC provider's authorization endpoint.
+// 🤠 Heady OIDC Login Endpoint - Initiate Authentik Authentication
+
+/**
+ * Initiates OIDC authentication flow with Authentik
+ * Creates authorization URL and redirects user to Authentik login
+ */
import type { APIRoute } from 'astro';
-import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
+import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { storeOidcState } from '../../../lib/auth/oidc-state.js';
import {
loadAuthentikConfig,
@@ -19,8 +21,10 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Login initiated');
+ // Load and validate Authentik configuration
const config = loadAuthentikConfig();
const validation = validateAuthentikConfig();
+
if (!validation.valid) {
console.error('❌ Invalid Authentik configuration:', validation.errors);
return new Response(
@@ -28,7 +32,10 @@ export const GET: APIRoute = async (context) => {
error: 'Authentication configuration error',
details: validation.errors,
}),
- { status: 500, headers: { 'Content-Type': 'application/json' } },
+ {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ },
);
}
@@ -38,34 +45,31 @@ export const GET: APIRoute = async (context) => {
console.log(` Redirect URI: ${config.redirectUri}`);
console.log(` Scopes: ${config.scopes.join(', ')}`);
- // Discover the provider once, then cache.
- const oidcConfig = await getOidcConfig(config);
+ // Create OIDC client
+ const oidcClient = new AuthentikOIDCClient(config);
- // PKCE + state + nonce. openid-client gives us cryptographically-secure
- // randoms for each.
- const codeVerifier = client.randomPKCECodeVerifier();
- const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
- const state = client.randomState();
- const nonce = client.randomNonce();
+ // Generate PKCE challenge
+ const pkce = await oidcClient.generatePKCE();
- // Stash everything the callback needs in the signed-cookie state store.
- // codeVerifier proves we're the original requester, state defends CSRF,
- // nonce binds the ID token to this specific login.
- await storeOidcState(context, { state, codeVerifier, nonce });
+ // Generate state parameter
+ const state = oidcClient.generateState();
- const authUrl = client.buildAuthorizationUrl(oidcConfig, {
- redirect_uri: config.redirectUri,
- scope: config.scopes.join(' '),
+ // Store {state, codeVerifier} in a signed cookie so the callback can
+ // retrieve them regardless of which worker handles it.
+ await storeOidcState(context, {
state,
- nonce,
- code_challenge: codeChallenge,
- code_challenge_method: 'S256',
+ codeVerifier: pkce.codeVerifier,
});
- console.log(`✓ Redirecting to authorization endpoint`);
- console.log(` State: ${state.slice(0, 10)}… Nonce: ${nonce.slice(0, 10)}…`);
+ // Build authorization URL
+ const authUrl = oidcClient.buildAuthorizationUrl(state, pkce);
- return redirect(authUrl.toString(), 302);
+ console.log(`✓ Redirecting to Authentik authorization endpoint`);
+ console.log(` State: ${state}`);
+ console.log(` PKCE Challenge: ${pkce.codeChallenge}`);
+
+ // Redirect to Authentik for authentication
+ return redirect(authUrl, 302);
} catch (error) {
console.error('❌ OIDC login error:', error);
@@ -80,7 +84,10 @@ export const GET: APIRoute = async (context) => {
'Check network connectivity to Authentik server',
],
}),
- { status: 500, headers: { 'Content-Type': 'application/json' } },
+ {
+ status: 500,
+ headers: { 'Content-Type': 'application/json' },
+ },
);
}
};
diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/logout.ts
index 4c608b2..0f253d9 100644
--- a/src/pages/api/auth/logout.ts
+++ b/src/pages/api/auth/logout.ts
@@ -6,7 +6,7 @@
*/
import type { APIRoute } from 'astro';
-import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
+import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
import { loadAuthentikConfig } from '../../../lib/config/authentik.js';
@@ -43,19 +43,28 @@ export const POST: APIRoute = async ({ request, url, cookies }) => {
console.log(' → Initiating full OIDC logout with Authentik');
try {
- // openid-client's buildEndSessionUrl uses the authoritative
- // end_session_endpoint published in the discovery document, not
- // the convention-built fallback (which may 404 against some IdPs).
+ // Load configuration and create OIDC client for logout URL
const config = loadAuthentikConfig();
- const oidcConfig = await getOidcConfig(config);
- const logoutUrl = client.buildEndSessionUrl(oidcConfig, {
- post_logout_redirect_uri: `${url.origin}/login?message=logout_successful`,
- });
+ const oidcClient = new AuthentikOIDCClient(config);
+
+ // Ensure discovery cache is populated so buildLogoutUrl can use
+ // the authoritative end_session_endpoint from the IdP instead of
+ // the convention-built fallback (which may be a 404).
+ await oidcClient.discover();
+
+ // Get Authentik logout URL
+ const logoutUrl = oidcClient.buildLogoutUrl(
+ `${url.origin}/login?message=logout_successful`,
+ );
console.log(`✓ Redirecting to Authentik logout: ${logoutUrl}`);
+
+ // Redirect to Authentik logout (session cookie was already cleared)
return new Response(null, {
status: 302,
- headers: { Location: logoutUrl.toString() },
+ headers: {
+ Location: logoutUrl,
+ },
});
} catch (error) {
console.warn(
diff --git a/src/pages/api/auth/status.ts b/src/pages/api/auth/status.ts
index 1970fd8..69414be 100644
--- a/src/pages/api/auth/status.ts
+++ b/src/pages/api/auth/status.ts
@@ -6,7 +6,10 @@
*/
import type { APIRoute } from 'astro';
-import { getSessionManager } from '../../../lib/auth/session-manager.js';
+import {
+ getActiveSessionCount,
+ getSessionManager,
+} from '../../../lib/auth/session-manager.js';
import {
getRoleMappingConfig,
loadAuthentikConfig,
@@ -101,7 +104,7 @@ export const GET: APIRoute = async ({ url, cookies }) => {
}
// Get session statistics (simplified for now)
- const activeSessionCount = await sessionMgr.getActiveCount();
+ const activeSessionCount = getActiveSessionCount();
const detailedStatus = {
...basicStatus,
diff --git a/src/pages/api/dns/magic.ts b/src/pages/api/dns/magic.ts
index 5325369..eb0e8b6 100644
--- a/src/pages/api/dns/magic.ts
+++ b/src/pages/api/dns/magic.ts
@@ -1,9 +1,6 @@
// Heady DNS Magic API - Alpine.js/Astro Magic DNS Toggle 🤠
import type { APIRoute } from 'astro';
-// Force SSR — auth middleware must run on every request.
-export const prerender = false;
-
export const POST: APIRoute = async ({ request }) => {
try {
const { enabled } = await request.json();
diff --git a/src/pages/api/dns/tailnet.ts b/src/pages/api/dns/tailnet.ts
index 05058ec..38247a4 100644
--- a/src/pages/api/dns/tailnet.ts
+++ b/src/pages/api/dns/tailnet.ts
@@ -1,9 +1,6 @@
// Heady DNS Tailnet API - Alpine.js/Astro Tailnet Name Management 🤠
import type { APIRoute } from 'astro';
-// Force SSR — auth middleware must run on every request.
-export const prerender = false;
-
export const POST: APIRoute = async ({ request }) => {
try {
const { baseDomain } = await request.json();
diff --git a/src/pages/api/settings/auth-keys.ts b/src/pages/api/settings/auth-keys.ts
index a0c53dd..f752a49 100644
--- a/src/pages/api/settings/auth-keys.ts
+++ b/src/pages/api/settings/auth-keys.ts
@@ -1,9 +1,6 @@
// Heady Settings Auth Keys API - Alpine.js/Astro Auth Key Management 🤠
import type { APIRoute } from 'astro';
-// Force SSR — auth middleware must run on every request.
-export const prerender = false;
-
// Mock auth keys storage (in production, this would interface with Headscale API)
const mockAuthKeys = [
{
diff --git a/src/pages/api/users.ts b/src/pages/api/users.ts
index a208799..93bd2ca 100644
--- a/src/pages/api/users.ts
+++ b/src/pages/api/users.ts
@@ -1,9 +1,6 @@
// Heady Users API - Alpine.js/Astro User Management Endpoint 🤠
import type { APIRoute } from 'astro';
-// Force SSR — auth middleware must run on every request.
-export const prerender = false;
-
// Mock users storage (in production, this would interface with Headscale API and OIDC)
const mockUsers = [
{
diff --git a/src/pages/dns.astro b/src/pages/dns.astro
index 5fa82d2..cd68d35 100644
--- a/src/pages/dns.astro
+++ b/src/pages/dns.astro
@@ -1,33 +1,694 @@
---
-// 🌐 Heady DNS — Astro shell, React island.
+import { getCollection } from 'astro:content';
+// Heady DNS Management - Alpine.js/Astro DNS Configuration 🤠
+import Layout from '../layouts/Layout.astro';
-import { DnsPage } from '@/components/dns/DnsPage';
-import Layout from '@/layouts/Layout.astro';
-
-// SSR: gated by role, must run auth middleware every request.
-export const prerender = false;
-
-// Sample DNS config until /api/dns is wired to Headscale.
-const initial = {
+// Mock DNS configuration data (in production, this would come from Headscale config)
+const dnsConfig = {
+ // Network prefixes from Headscale
prefixes: ['100.64.0.0/10', 'fd7a:115c:a1e0::/48'],
+
+ // Magic DNS configuration
magicDns: true,
baseDomain: 'heady.local',
+
+ // Global nameservers
nameservers: ['1.1.1.1', '8.8.8.8', '9.9.9.9'],
+
+ // Split DNS configuration
splitDns: {
- 'corp.example.com': ['10.0.0.1', '10.0.0.2'],
+ 'corp.company.com': ['10.0.0.1', '10.0.0.2'],
'internal.dev': ['192.168.1.1'],
},
- searchDomains: ['example.com', 'dev.local', 'staging.local'],
+
+ // Search domains
+ searchDomains: ['company.com', 'dev.local', 'staging.local'],
+
+ // Override local DNS
overrideDns: true,
+
+ // Extra DNS records
extraRecords: [
- { name: 'api.heady.local', type: 'A' as const, value: '100.64.0.10' },
- { name: 'db.heady.local', type: 'A' as const, value: '100.64.0.11' },
- { name: 'web.heady.local', type: 'A' as const, value: '100.64.0.12' },
- { name: 'vpn.heady.local', type: 'AAAA' as const, value: 'fd7a:115c:a1e0::1' },
+ { name: 'api.heady.local', type: 'A', value: '100.64.0.10' },
+ { name: 'db.heady.local', type: 'A', value: '100.64.0.11' },
+ { name: 'web.heady.local', type: 'A', value: '100.64.0.12' },
+ { name: 'vpn.heady.local', type: 'AAAA', value: 'fd7a:115c:a1e0::1' },
],
+
+ // Permissions
+ access: true, // User can modify DNS settings
+ writable: true, // Configuration is writable (not read-only)
};
+
+// Get recent DNS-related activity
+const recentActivity = await getCollection(
+ 'activity',
+ ({ data }) => data.resource_type === 'dns',
+).then((items) =>
+ items
+ .slice(0, 5)
+ .sort(
+ (a, b) =>
+ new Date(b.data.timestamp).getTime() -
+ new Date(a.data.timestamp).getTime(),
+ ),
+);
---
-
-
-
+
+
+
+
+
+
+
+ 🌐
+ DNS Configuration
+
+
+ Configure DNS settings for your Heady network including Magic DNS, custom records,
+ nameservers, and search domains.
+
+
+
+
+
+
+
+
⚠️
+
+
Read-only Configuration
+
+ The Headscale configuration is read-only. You cannot make changes to the DNS configuration.
+
+
+
+
+
+
+
+
+
+
🚫
+
+
Insufficient Permissions
+
+ Your permissions do not allow you to modify the DNS settings for this tailnet.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🏷️
+ Tailnet Name
+
+
+ The base domain for your tailnet. When Magic DNS is enabled, machines will be accessible
+ at machine.{dnsConfig.baseDomain}
+
+
+
+
+
+
+
+
+ ✨
+ Magic DNS
+
+
+ Automatically register domain names for each device on the tailnet.
+ Devices will be accessible at [device].{dnsConfig.baseDomain}
+ when Magic DNS is enabled.
+
+
+
+
+
+
+
+ ✅ Active
+
+
+ ⭕ Inactive
+
+
+
+
+
+
+
+
+ 🌍
+ Nameservers
+
+
+ Configure global and split DNS nameservers for your tailnet.
+
+
+
+
+
Global Nameservers
+
+
+
+
+
+ 🗑️
+
+
+
+
+
+
+ + Add Nameserver
+
+
+
+
+
+
+
+
Split DNS
+
+
+
+
+
+
+
+
+
+ + Add Split DNS
+
+
+
+
+
+
+
+
+
+ 📝
+ Custom DNS Records
+
+
+ Add custom DNS records to your tailnet. Only A and AAAA records are currently supported.
+
+ Learn More
+
+
+
+
+
+
+
📋
+
No DNS records found
+
+
+
+
+
+
+
+
+
+
+
+
+ + Add DNS Record
+
+
+
+
+
+
+
+
+ 🔍
+ Search Domains
+
+
+ Set custom DNS search domains for your tailnet. When using Magic DNS,
+ your tailnet domain is used as the first search domain.
+
+
+
+
+
+
+ 🔒
+
+ Magic DNS
+
+
+
+
+
+
+
+
+ ⋮⋮
+
+
+
+
+ 🗑️
+
+
+
+
+
+
+
+
+
+ Add
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📊
+ DNS Status
+
+
+
+ Magic DNS:
+
+
+
+
+
+ Base Domain:
+
+
+
+ Global NS:
+
+
+
+ Split DNS:
+
+
+
+ DNS Records:
+
+
+
+ Search Domains:
+
+
+
+
+
+
+
+
+ 🌐
+ Network Prefixes
+
+
+
+
+
+ {recentActivity.length > 0 && (
+
+
+ 🕐
+ Recent Changes
+
+
+ {recentActivity.map(activity => (
+
+
+
+
{activity.data.action}
+
+ {activity.data.user_email} • {new Date(activity.data.timestamp).toLocaleString()}
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/index.astro b/src/pages/index.astro
index b2c6bfc..9f7c379 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,43 +1,373 @@
---
-// 🤠 Heady Dashboard — Astro shell, React island.
-// The whole page (nav, auth gate, content) lives in one React tree so we
-// get shadcn-ui styling end-to-end. The Astro page just resolves server
-// data from content collections and hands it down as props.
-
import { getCollection } from 'astro:content';
-import { DashboardPage } from '@/components/dashboard/Dashboard';
-import Layout from '@/layouts/Layout.astro';
-
-// SSR: renders per-user dashboard, must run auth middleware on every request.
-export const prerender = false;
+import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
+// Heady Dashboard - Alpine.js/Astro Homepage 🤠
+import Layout from '../layouts/Layout.astro';
+// Fetch live data using Astro content collections
const allMachines = await getCollection('machines');
-const allSessions = await getCollection(
+const activeSessions = await getCollection(
'sessions',
({ data }) => data.status === 'active',
);
+const recentActivity = await getCollection('activity').then((items) =>
+ items
+ .slice(0, 10)
+ .sort(
+ (a, b) =>
+ new Date(b.data.timestamp).getTime() -
+ new Date(a.data.timestamp).getTime(),
+ ),
+);
-const machines = allMachines.map((m) => ({
- id: (m.data as { id?: string }).id ?? m.id,
- name: m.data.name,
- ip_address: m.data.ip_address,
- os: m.data.os,
- online: m.data.online,
-}));
+// Calculate dashboard stats
+const onlineMachines = allMachines.filter((m) => m.data.online).length;
+const totalMachines = allMachines.length;
+const activeSessionCount = activeSessions.length;
-const activeSessions = allSessions.map((s) => ({
- id: (s.data as { id?: string }).id ?? s.id,
- node_name: s.data.node_name,
- user_email: s.data.user_email,
- protocol: s.data.protocol,
- status: s.data.status,
-}));
+// System health indicators
+const systemHealth = {
+ headscale: true, // Would be determined by actual health checks
+ database: true,
+ oidc: true,
+ remoteAccess: true,
+};
---
-
-
-
+
+
+
+
+
+
+
+
+
+ 🤠
+ Welcome to Heady
+
+
+ Strategic VPN management that's actually awesome to use
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Online Machines
+
+ {onlineMachines}
+ / {totalMachines}
+
+
+
+
+
+
+
+
+
+
+
+
+ Active Sessions
+ {activeSessionCount}
+
+
+
+
+
+
+
+
+
+
h) ? 'bg-green-500' : 'bg-yellow-500'
+ }`}>
+ 🛡️
+
+
+
+
+ System Health
+
+ {Object.values(systemHealth).every(h => h) ? 'Excellent' : 'Warning'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Network
+ Healthy
+
+
+
+
+
+
+
+
+
+
+
+
+ 🖥️
+ Machines Overview
+
+
+
+
+ {allMachines.slice(0, 5).map(machine => (
+
+
+
+
+
{machine.data.name}
+
{machine.data.ip_address}
+
+
+
+ {machine.data.os && (
+
+ {machine.data.os === 'linux' ? '🐧' :
+ machine.data.os === 'windows' ? '🪟' :
+ machine.data.os === 'macos' ? '🍎' : '❓'}
+ {machine.data.os}
+
+ )}
+
+ {machine.data.online ? 'Online' : 'Offline'}
+
+
+
+ ))}
+
+
+ {allMachines.length > 5 && (
+
+ )}
+
+
+
+
+
+
+
+ {activeSessions.length > 0 && (
+
+
+
+ ⚡
+ Active Sessions
+
+
+
+
+ {activeSessions.slice(0, 3).map(session => (
+
+
+
+ {session.data.protocol.toUpperCase()}
+
+ {session.data.node_name}
+
+
+ {session.data.user_email.split('@')[0]}
+
+
+ ))}
+
+
+ {activeSessions.length > 3 && (
+
+ )}
+
+
+ )}
+
+
+
+
+
+ 🛡️
+ System Health
+
+
+
+
+
+ Headscale
+
+ {systemHealth.headscale ? '✅ Healthy' : '❌ Unhealthy'}
+
+
+
+ Database
+
+ {systemHealth.database ? '✅ Connected' : '❌ Disconnected'}
+
+
+
+ OIDC Auth
+
+ {systemHealth.oidc ? '✅ Active' : '❌ Error'}
+
+
+
+ Remote Access
+
+ {systemHealth.remoteAccess ? '✅ Ready' : '❌ Unavailable'}
+
+
+
+
+
+
+
+
+
+
+ 🚀
+ Quick Actions
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/login.astro b/src/pages/login.astro
index 88f5a35..4163a8b 100644
--- a/src/pages/login.astro
+++ b/src/pages/login.astro
@@ -147,7 +147,7 @@ const displayMessage =
-
+
\ No newline at end of file
diff --git a/src/pages/settings.astro b/src/pages/settings.astro
index b40f2af..6ed5bb1 100644
--- a/src/pages/settings.astro
+++ b/src/pages/settings.astro
@@ -1,99 +1,933 @@
---
-// ⚙️ Heady Settings — Astro shell, React island.
-//
-// Pulls live config from the modules that already read it (OIDC + session
-// + role mapping). Headscale-specific fields are flagged as "connect
-// Headscale" placeholders until we wire up its API.
+import { getCollection } from 'astro:content';
+// Heady Settings - Alpine.js/Astro Settings & Configuration 🤠
+import Layout from '../layouts/Layout.astro';
-import { getSessionManager } from '@/lib/auth/session-manager';
-import {
- getRoleMappingConfig,
- getSessionConfig,
- loadAuthentikConfig,
-} from '@/lib/config/authentik';
-import { SettingsPage } from '@/components/settings/SettingsPage';
-import Layout from '@/layouts/Layout.astro';
-
-// Force SSR — config reads env vars at request time.
-export const prerender = false;
-
-const authentik = loadAuthentikConfig();
-const session = getSessionConfig();
-const roleMapping = getRoleMappingConfig();
-const activeSessions = await getSessionManager().getActiveCount();
-
-// Combine env-derived and Headscale-derived fields. Anything we can't
-// resolve yet shows a clearly-flagged placeholder so the operator can tell
-// what's real vs. pending integration.
-const HEADSCALE_PLACEHOLDER = 'connect Headscale to populate';
-
-const settings = {
+// Mock settings data (in production, this would come from Headscale config)
+const settingsData = {
+ // Server configuration
server: {
- version: HEADSCALE_PLACEHOLDER,
- url: HEADSCALE_PLACEHOLDER,
- publicUrl: authentik.redirectUri.replace('/api/auth/callback', ''),
- grpcListenAddr: HEADSCALE_PLACEHOLDER,
- metricsListenAddr: HEADSCALE_PLACEHOLDER,
+ version: '0.23.0',
+ url: 'https://headscale.company.com',
+ publicUrl: 'https://vpn.company.com',
+ grpcListenAddr: '0.0.0.0:50443',
+ metricsListenAddr: '127.0.0.1:9090',
},
+
+ // Configuration permissions
+ config: {
+ writable: true, // Can modify configuration
+ readable: true, // Can read configuration
+ },
+
+ // OIDC configuration
oidc: {
enabled: true,
- issuer: authentik.issuer,
- clientId: authentik.clientId,
- stripEmailDomain: false, // Heady doesn't strip — we use full email as session id
- scope: authentik.scopes,
- allowedDomains: [] as string[], // not modeled yet — managed in Authentik
- allowedGroups: [
- ...roleMapping.ownerGroups,
- ...roleMapping.adminGroups,
- ...roleMapping.networkGroups,
- ...roleMapping.itGroups,
- ...roleMapping.auditorGroups,
- ],
+ issuer: 'https://auth.company.com',
+ clientId: 'heady-app',
+ clientSecret: '***hidden***',
+ stripEmailDomain: true,
+ scope: ['openid', 'profile', 'email'],
+ allowedDomains: ['company.com', 'contractor.com'],
+ allowedGroups: ['engineering', 'admins', 'network-team'],
+ adminGroup: 'admins',
},
+
+ // Database configuration
database: {
- type: HEADSCALE_PLACEHOLDER,
- sqlite: { path: HEADSCALE_PLACEHOLDER },
- },
- tls: {
- letsencrypt: {
- hostname: HEADSCALE_PLACEHOLDER,
- challengeType: HEADSCALE_PLACEHOLDER,
+ type: 'sqlite',
+ sqlite: {
+ path: '/var/lib/headscale/db.sqlite',
},
},
+
+ // TLS configuration
+ tls: {
+ letsencrypt: {
+ hostname: 'vpn.company.com',
+ listen: ':https',
+ challengeType: 'TLS-ALPN-01',
+ },
+ },
+
+ // Log configuration
log: {
- level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
+ level: 'info',
format: 'text',
},
};
-// Heady-specific runtime info — these we DO know.
-const heady = {
- sessionStore: process.env.REDIS_URL ? 'redis' : 'in-memory',
- sessionLifetime: `${Math.round(session.lifetime / 1000 / 60 / 60)}h`,
- sessionCookieName: session.cookieName,
- cookieSecure: session.secure,
- activeSessions,
- nodeVersion: process.version,
- processUptime: Math.round(process.uptime()),
-};
-
-// Auth keys still aren't reachable without Headscale; leave the list empty
-// and let the UI render its empty state.
-const authKeys: never[] = [];
+// Mock auth keys data
+const authKeysData = [
+ {
+ id: '1',
+ key: 'authkey-abc123def456...',
+ keyPrefix: 'authkey-abc123',
+ reusable: false,
+ ephemeral: false,
+ used: false,
+ expiration: '2024-02-15T10:30:00Z',
+ createdAt: '2024-01-15T10:30:00Z',
+ user: {
+ id: 'user-1',
+ name: 'alice',
+ email: 'alice@company.com',
+ },
+ },
+ {
+ id: '2',
+ key: 'authkey-xyz789uvw012...',
+ keyPrefix: 'authkey-xyz789',
+ reusable: true,
+ ephemeral: false,
+ used: true,
+ expiration: '2024-12-31T23:59:59Z',
+ createdAt: '2024-01-10T14:20:00Z',
+ user: {
+ id: 'user-2',
+ name: 'system',
+ email: 'system@company.com',
+ },
+ },
+];
+// User permissions
const permissions = {
canGenerateAuthKeys: true,
canModifyConfig: true,
canViewLogs: true,
};
+
+// Get recent settings-related activity
+const recentActivity = await getCollection('activity', ({ data }) =>
+ ['system', 'auth'].includes(data.resource_type),
+).then((items) =>
+ items
+ .slice(0, 5)
+ .sort(
+ (a, b) =>
+ new Date(b.data.timestamp).getTime() -
+ new Date(a.data.timestamp).getTime(),
+ ),
+);
---
-
-
-
+
+
+
+
+
+
+
+ ⚙️ Settings
+
+
+ Configure your Heady installation, manage authentication keys, and control access restrictions.
+
+
+
+
+
+
+
+
+
+
+ 📊 Overview
+
+
+
+ 🗝️ Auth Keys
+
+
+
+ 🔐 OIDC
+
+
+
+ 🛡️ Restrictions
+
+
+
+ 🔧 Advanced
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🖥️ Server Information
+
+
+
Configuration
+
+
+ Version:
+
+
+
+ URL:
+
+
+
+ Public URL:
+
+
+
+ gRPC Listen:
+
+
+
+
+
+
+
Database
+
+
+ Type:
+
+
+
+ Path:
+
+
+
+
+
+
+
+
+
🚀 Quick Actions
+
+
+
+
+
+
🗝️ Generate Auth Key
+
Create new pre-authentication key
+
+
→
+
+
+
+
+
+
+
🔐 Configure OIDC
+
Manage authentication settings
+
+
→
+
+
+
+
+
+
+
🛡️ Access Restrictions
+
Control user access limits
+
+
→
+
+
+
+
+
+
+
+
+
+
+
+
🗝️ Pre-Authentication Keys
+
+ Pre-auth keys allow devices to join your network without manual approval.
+
+ Learn more
+
+
+
+
+
+
+ ➕
+ Generate Key
+
+
+
+
+
+
+
+ All Users
+
+
+
+
+
+
+ All Status
+ Active
+ Expired/Used
+ Reusable
+ Ephemeral
+
+
+
+
+
+
+
+
+
+
🗝️
+
No auth keys found
+
+
+ Generate your first auth key to get started.
+
+
+ Try adjusting your filters to see more keys.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reusable
+
+
+ Ephemeral
+
+
+ Used
+
+
+ Expired
+
+
+ Active
+
+
+
+
+
+
+ User:
+
+
+
+ Created:
+
+
+
+ Expires:
+
+
+
+ Status:
+
+
+
+
+
+
+
+
+ 📋
+
+
+ ❌
+
+
+
+
+
+
+
+
+
+
+
🔐 OIDC Configuration
+
+
+
+
+
+
+
Authentication Provider
+
+
+
+
+ Issuer URL
+
+
+
+
+ Client ID
+
+
+
+
+
+
+
+
+
+
Access Control
+
+
+
Allowed Domains
+
+
+
+
+
+ 🗑️
+
+
+
+
+ + Add Domain
+
+
+
+
+
+
Allowed Groups
+
+
+
+
+
+ 🗑️
+
+
+
+
+ + Add Group
+
+
+
+
+
+
+
+
+
+
+
+
🛡️ Authentication Restrictions
+
+
🚧
+
Feature Coming Soon
+
+ Advanced authentication restrictions will be available in a future update.
+
+
+
+
+
+
+
🔧 Advanced Settings
+
+
+
+
+
⚠️
+
+
Advanced Configuration
+
+ These settings can affect the security and functionality of your network.
+ Only modify them if you understand the implications.
+
+
+
+
+
+
+
+
TLS Configuration
+
+
+ Hostname:
+
+
+
+ Challenge:
+
+
+
+
+
+
+
Logging
+
+
+ Level:
+
+
+
+ Format:
+
+
+
+
+
+
+
+
+
+
+
+
+
🗝️ Generate Auth Key
+
+
+ User
+
+
+
+
+
+
+
+ Expiration
+
+ 1 hour
+ 24 hours
+ 7 days
+ 30 days
+ 90 days
+ 1 year
+
+
+
+
+
+ Reusable
+
+
+
+ Ephemeral
+
+
+
+
+
+
+
+
+ Cancel
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/terminal.astro b/src/pages/terminal.astro
index 8d922c1..4107e27 100644
--- a/src/pages/terminal.astro
+++ b/src/pages/terminal.astro
@@ -414,7 +414,7 @@ const availableProtocols = Object.entries(userPermissions)
-
+
\ No newline at end of file
diff --git a/src/styles/global.css b/src/styles/global.css
index 6df7077..b5c61c9 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1,75 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-
-/* shadcn-ui (new-york) theme tokens.
- We default to dark for Heady's identity; `:root` and `.dark` carry the
- same palette so anywhere we toggle theme later, the dark scheme is
- consistent. Swap the .dark block back to the light defaults if you want
- theme switching back. */
-
-@layer base {
- :root {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
-
- --card: 222.2 47.4% 11.2%;
- --card-foreground: 210 40% 98%;
-
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
-
- --primary: 217.2 91.2% 59.8%;
- --primary-foreground: 222.2 47.4% 11.2%;
-
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
-
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
-
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
-
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
-
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 224.3 76.3% 48%;
-
- --radius: 0.5rem;
- }
-
- .dark {
- --background: 222.2 84% 4.9%;
- --foreground: 210 40% 98%;
- --card: 222.2 47.4% 11.2%;
- --card-foreground: 210 40% 98%;
- --popover: 222.2 84% 4.9%;
- --popover-foreground: 210 40% 98%;
- --primary: 217.2 91.2% 59.8%;
- --primary-foreground: 222.2 47.4% 11.2%;
- --secondary: 217.2 32.6% 17.5%;
- --secondary-foreground: 210 40% 98%;
- --muted: 217.2 32.6% 17.5%;
- --muted-foreground: 215 20.2% 65.1%;
- --accent: 217.2 32.6% 17.5%;
- --accent-foreground: 210 40% 98%;
- --destructive: 0 62.8% 30.6%;
- --destructive-foreground: 210 40% 98%;
- --border: 217.2 32.6% 17.5%;
- --input: 217.2 32.6% 17.5%;
- --ring: 224.3 76.3% 48%;
- }
-}
-
-@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- font-feature-settings: 'rlig' 1, 'calt' 1;
- }
-}
diff --git a/tailwind.config.mjs b/tailwind.config.mjs
index b095d1e..684a316 100644
--- a/tailwind.config.mjs
+++ b/tailwind.config.mjs
@@ -1,59 +1,15 @@
/** @type {import('tailwindcss').Config} */
-import tailwindcssAnimate from 'tailwindcss-animate';
-
export default {
- darkMode: 'class',
content: [
- './src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
+ './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
'./src/pages/**/*.astro',
'./src/layouts/**/*.astro',
- './src/components/**/*.{astro,tsx}',
+ './src/components/**/*.astro',
],
theme: {
- container: {
- center: true,
- padding: '2rem',
- screens: { '2xl': '1400px' },
- },
extend: {
- // shadcn-ui (new-york) reads colors from CSS variables defined in
- // src/styles/global.css. Everything below maps Tailwind utility
- // classes to those variables.
colors: {
- border: 'hsl(var(--border))',
- input: 'hsl(var(--input))',
- ring: 'hsl(var(--ring))',
- background: 'hsl(var(--background))',
- foreground: 'hsl(var(--foreground))',
- primary: {
- DEFAULT: 'hsl(var(--primary))',
- foreground: 'hsl(var(--primary-foreground))',
- },
- secondary: {
- DEFAULT: 'hsl(var(--secondary))',
- foreground: 'hsl(var(--secondary-foreground))',
- },
- destructive: {
- DEFAULT: 'hsl(var(--destructive))',
- foreground: 'hsl(var(--destructive-foreground))',
- },
- muted: {
- DEFAULT: 'hsl(var(--muted))',
- foreground: 'hsl(var(--muted-foreground))',
- },
- accent: {
- DEFAULT: 'hsl(var(--accent))',
- foreground: 'hsl(var(--accent-foreground))',
- },
- popover: {
- DEFAULT: 'hsl(var(--popover))',
- foreground: 'hsl(var(--popover-foreground))',
- },
- card: {
- DEFAULT: 'hsl(var(--card))',
- foreground: 'hsl(var(--card-foreground))',
- },
- // Brand accents kept from the original Heady config.
+ // Heady brand colors
heady: {
50: '#f0f9ff',
100: '#e0f2fe',
@@ -67,30 +23,21 @@ export default {
900: '#0c4a6e',
},
},
- borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
- },
fontFamily: {
- mono: ['Fira Code', 'Monaco', 'Cascadia Code', 'monospace'],
- },
- keyframes: {
- 'accordion-down': {
- from: { height: '0' },
- to: { height: 'var(--radix-accordion-content-height)' },
- },
- 'accordion-up': {
- from: { height: 'var(--radix-accordion-content-height)' },
- to: { height: '0' },
- },
+ mono: [
+ 'Fira Code',
+ 'Monaco',
+ 'Cascadia Code',
+ 'Roboto Mono',
+ 'monospace',
+ ],
},
animation: {
- 'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
},
},
- plugins: [tailwindcssAnimate],
+ plugins: [
+ // Add any Tailwind plugins you need
+ ],
};
diff --git a/tsconfig.json b/tsconfig.json
index 27a93b5..3fc47aa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,11 +21,9 @@
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
- "baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
- "~server/*": ["./server/*"],
- "@/*": ["./src/*"]
+ "~server/*": ["./server/*"]
},
// Vite takes care of building everything, not tsc.