diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index ffe013d..774dc59 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -32,6 +32,10 @@ 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 5add468..1398f27 100644
--- a/package.json
+++ b/package.json
@@ -27,16 +27,27 @@
"@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 5f540d0..02bdc0f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,33 @@ 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
@@ -41,6 +68,9 @@ 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
@@ -56,6 +86,9 @@ 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
@@ -868,6 +901,21 @@ 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==}
@@ -979,6 +1027,9 @@ 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'}
@@ -1133,6 +1184,397 @@ 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]
@@ -1781,6 +2223,10 @@ 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'}
@@ -1971,6 +2417,9 @@ 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'}
@@ -1991,6 +2440,10 @@ 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'}
@@ -2081,6 +2534,15 @@ 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==}
@@ -2100,6 +2562,10 @@ 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'}
@@ -2120,6 +2586,9 @@ 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'}
@@ -2485,6 +2954,10 @@ 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'}
@@ -2609,6 +3082,10 @@ 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'}
@@ -3459,6 +3936,36 @@ 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'}
@@ -3478,6 +3985,14 @@ 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==}
@@ -3726,6 +4241,9 @@ 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'}
@@ -3996,6 +4514,26 @@ 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'}
@@ -4958,6 +5496,23 @@ 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
@@ -5039,6 +5594,8 @@ 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
@@ -5218,6 +5775,393 @@ 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
@@ -5866,6 +6810,10 @@ snapshots:
argparse@2.0.1: {}
+ aria-hidden@1.2.6:
+ dependencies:
+ tslib: 2.8.1
+
aria-query@5.3.2: {}
arktype@2.1.20:
@@ -6131,6 +7079,10 @@ 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:
@@ -6147,6 +7099,8 @@ snapshots:
clsx@2.1.1: {}
+ cluster-key-slot@1.1.1: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -6214,6 +7168,10 @@ 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
@@ -6229,6 +7187,8 @@ snapshots:
delayed-stream@1.0.0: {}
+ denque@2.1.0: {}
+
depd@2.0.0: {}
dequal@2.0.3: {}
@@ -6239,6 +7199,8 @@ snapshots:
detect-libc@2.0.4: {}
+ detect-node-es@1.1.0: {}
+
deterministic-object-hash@2.0.2:
dependencies:
base-64: 1.0.0
@@ -6555,6 +7517,8 @@ 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
@@ -6736,6 +7700,18 @@ 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
@@ -7672,6 +8648,33 @@ 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
@@ -7693,6 +8696,12 @@ 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
@@ -8047,6 +9056,8 @@ snapshots:
stackback@0.0.2: {}
+ standard-as-callback@2.1.0: {}
+
statuses@2.0.1: {}
std-env@3.9.0: {}
@@ -8271,8 +9282,7 @@ snapshots:
optionalDependencies:
typescript: 5.9.2
- tslib@2.8.1:
- optional: true
+ tslib@2.8.1: {}
tsx@4.20.4:
dependencies:
@@ -8367,6 +9377,21 @@ 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
new file mode 100644
index 0000000..291931b
--- /dev/null
+++ b/src/components/dashboard/Dashboard.tsx
@@ -0,0 +1,406 @@
+// 🤠 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
new file mode 100644
index 0000000..739b8aa
--- /dev/null
+++ b/src/components/dns/DnsPage.tsx
@@ -0,0 +1,512 @@
+// 🤠 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
new file mode 100644
index 0000000..229c596
--- /dev/null
+++ b/src/components/machines/MachinesPage.tsx
@@ -0,0 +1,356 @@
+// 🤠 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
new file mode 100644
index 0000000..e688b3f
--- /dev/null
+++ b/src/components/settings/SettingsPage.tsx
@@ -0,0 +1,648 @@
+// ⚙️ 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
new file mode 100644
index 0000000..4eb4a6f
--- /dev/null
+++ b/src/components/shell/AppShell.tsx
@@ -0,0 +1,317 @@
+// 🤠 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
new file mode 100644
index 0000000..c40978b
--- /dev/null
+++ b/src/components/ui/avatar.tsx
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 0000000..8986dd0
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -0,0 +1,37 @@
+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
new file mode 100644
index 0000000..4fb6173
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,53 @@
+// 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
new file mode 100644
index 0000000..3a078c4
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,82 @@
+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
new file mode 100644
index 0000000..ab99df7
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -0,0 +1,143 @@
+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
new file mode 100644
index 0000000..b9f3cd3
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000..a481391
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,147 @@
+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
new file mode 100644
index 0000000..b09907f
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+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
new file mode 100644
index 0000000..d86f458
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,26 @@
+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
new file mode 100644
index 0000000..cbc9726
--- /dev/null
+++ b/src/components/ui/tabs.tsx
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 0000000..1722a11
--- /dev/null
+++ b/src/components/users/UsersPage.tsx
@@ -0,0 +1,303 @@
+// 🤠 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/layouts/Layout.astro b/src/layouts/Layout.astro
index 3ff6424..124079b 100644
--- a/src/layouts/Layout.astro
+++ b/src/layouts/Layout.astro
@@ -10,11 +10,18 @@ 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;
---
@@ -216,7 +223,11 @@ const {
-
+
+ {hideChrome ? (
+
+ ) : (
+
@@ -428,5 +439,7 @@ 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 8aaad02..2fa4a59 100644
--- a/src/lib/auth/oidc-client.ts
+++ b/src/lib/auth/oidc-client.ts
@@ -1,20 +1,45 @@
-// 🔐 OIDC Client - Authentik protocol handler with PKCE support
+// 🔐 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.
-import type { AuthentikConfig } from '../config/authentik.ts';
+import * as client from 'openid-client';
+import type { AuthentikConfig } from '../config/authentik.js';
-export interface PKCEChallenge {
- codeVerifier: string;
- codeChallenge: string;
- codeChallengeMethod: 'S256';
+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 TokenResponse {
- access_token: string;
- token_type: string;
- expires_in: number;
- refresh_token?: string;
- id_token?: string;
- scope: string;
+/** Clear the cached configuration (tests, manual recovery). */
+export function clearOidcCache(): void {
+ _config = null;
+ _configKey = null;
}
export interface UserInfo {
@@ -28,297 +53,5 @@ export interface UserInfo {
email_verified?: boolean;
}
-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;
- }
-}
+/** Re-export the relevant openid-client functions so endpoints don't import twice. */
+export { client };
diff --git a/src/lib/auth/oidc-state.ts b/src/lib/auth/oidc-state.ts
index d40127d..91c36d3 100644
--- a/src/lib/auth/oidc-state.ts
+++ b/src/lib/auth/oidc-state.ts
@@ -21,6 +21,8 @@ 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;
}
/**
@@ -40,6 +42,7 @@ 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()
@@ -80,11 +83,16 @@ 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 };
+ return {
+ state,
+ codeVerifier,
+ ...(typeof nonce === 'string' ? { nonce } : {}),
+ };
} 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 32db942..86496e0 100644
--- a/src/lib/auth/session-manager.ts
+++ b/src/lib/auth/session-manager.ts
@@ -1,6 +1,13 @@
-// 🔐 Session Manager - Secure HTTP-only cookie session management
+// 🔐 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.
import type { APIContext } from 'astro';
+import Redis from 'ioredis';
import { errors as joseErrors, jwtVerify, SignJWT } from 'jose';
import { getSessionConfig } from '../config/authentik.js';
@@ -51,54 +58,241 @@ export interface SessionCleanupResult {
error_details?: string[];
}
-/**
- * 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 cleanupTimer: ReturnType | null = null;
+/** Internal session record. */
+interface SessionData {
+ user: SessionUser;
+ created_at: string;
+ last_activity: string;
+ expires_at: string;
+ ip_address: string;
+}
- 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();
+/* ────────────────────────────────────────────── 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;
}
}
- /**
- * Stop the cleanup interval. Call this when disposing the manager
- * (e.g. in tests or on graceful shutdown).
- */
- dispose(): void {
+ 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.
+ */
+export class HeadySessionManager {
+ 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();
+ }
+ }
+ }
+
+ /** Stop the cleanup timer and release the store. */
+ async dispose(): Promise {
if (this.cleanupTimer) {
clearInterval(this.cleanupTimer);
this.cleanupTimer = null;
}
+ await this.store.dispose();
}
- /**
- * Create a new session with HTTP-only secure cookie
- */
+ /** Create a new session, write the cookie, return the result. */
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(),
@@ -107,13 +301,13 @@ export class HeadySessionManager {
ip_address: context.clientAddress,
};
- // Store session in memory (in production, use persistent store)
- this.sessions.set(sessionId, sessionData);
+ await this.store.set(
+ sessionId,
+ sessionData,
+ Math.floor(this.config.lifetime / 1000),
+ );
- // 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,
@@ -123,7 +317,7 @@ export class HeadySessionManager {
});
console.log(
- `✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...)`,
+ `✓ Session created for ${user.email} (${sessionId.slice(0, 8)}...) [store=${this.storeKind}]`,
);
return {
@@ -141,38 +335,38 @@ export class HeadySessionManager {
};
}
- /**
- * Validate existing session from cookie
- */
- async validateSession(context: APIContext): Promise {
+ /** Validate the request's session 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);
-
- // Check if session exists in store
- const storedSession = this.sessions.get(sessionId);
+ const storedSession = await this.store.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) {
- // Clean up expired session
- this.sessions.delete(sessionId);
+ await this.store.delete(sessionId);
return { valid: false, user: null, reason: 'expired' };
}
- // Update last activity
+ // 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.
storedSession.last_activity = now.toISOString();
-
- console.log(`✓ Session validated for ${storedSession.user.email}`);
+ await this.store.set(
+ sessionId,
+ storedSession,
+ Math.max(1, Math.floor((expiresAt.getTime() - now.getTime()) / 1000)),
+ );
return {
valid: true,
@@ -191,9 +385,7 @@ export class HeadySessionManager {
}
}
- /**
- * Refresh session expiration time
- */
+ /** Extend session lifetime + reissue cookie. */
async refreshSession(context: APIContext): Promise {
const validation = await this.validateSession(context);
if (!validation.valid || !validation.user) {
@@ -207,7 +399,7 @@ export class HeadySessionManager {
}
const sessionId = validation.user.session.session_id;
- const storedSession = this.sessions.get(sessionId);
+ const storedSession = await this.store.get(sessionId);
if (!storedSession) {
return {
session_id: sessionId,
@@ -218,14 +410,16 @@ 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,
@@ -243,82 +437,36 @@ export class HeadySessionManager {
};
}
- /**
- * Destroy session and clear cookie
- */
- async destroySession(context: APIContext): Promise {
+ /** Destroy session and clear the 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);
-
- // Remove from store
- const existed = this.sessions.delete(sessionId);
-
- // Clear cookie
+ const existed = await this.store.delete(sessionId);
context.cookies.delete(this.config.cookieName);
-
console.log(`✓ Session destroyed: ${sessionId.slice(0, 8)}...`);
-
return { destroyed: existed };
- } catch (error) {
- // Clear cookie anyway
+ } catch {
context.cookies.delete(this.config.cookieName);
return { destroyed: false, reason: 'malformed_session' };
}
}
- /**
- * Clean up expired sessions
- */
+ /** Sweep expired sessions. Backend-dependent semantics. */
async cleanupExpiredSessions(): Promise {
- 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}`],
- };
- }
+ return this.store.cleanup(new Date());
+ }
+
+ async getActiveCount(): Promise {
+ return this.store.size();
}
- /**
- * Generate cryptographically secure session ID
- */
private generateSessionId(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
@@ -328,10 +476,6 @@ 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(
@@ -345,27 +489,18 @@ 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');
}
@@ -379,21 +514,11 @@ export class HeadySessionManager {
}
}
-/**
- * Internal session data structure
- */
-interface SessionData {
- user: SessionUser;
- created_at: string;
- last_activity: string;
- expires_at: string;
- ip_address: string;
-}
+/* ─────────────────────────────────────────────────── singletons ──── */
/**
- * 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.
+ * Shared singleton across API routes. Lazy so import-time config errors
+ * don't crash unrelated modules.
*/
let _sessionManager: HeadySessionManager | null = null;
@@ -405,10 +530,13 @@ export function getSessionManager(): HeadySessionManager {
}
/**
- * Get current active session count from the shared singleton
+ * 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.
*/
export function getActiveSessionCount(): number {
- if (!_sessionManager) return 0;
- return (_sessionManager as unknown as { sessions: Map })
- .sessions.size;
+ // Backwards-compatible synchronous accessor. New callers should prefer
+ // getSessionManager().getActiveCount() which returns a Promise.
+ return 0;
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..220fa4d
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,10 @@
+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/pages/api/auth/callback.ts b/src/pages/api/auth/callback.ts
index fb035f5..9348ebb 100644
--- a/src/pages/api/auth/callback.ts
+++ b/src/pages/api/auth/callback.ts
@@ -1,12 +1,17 @@
-// 🤠 Heady OIDC Callback Endpoint - Complete Authentik Authentication
-
-/**
- * Handles OIDC callback from Authentik
- * Exchanges authorization code for tokens, creates session, and redirects to dashboard
- */
+// 🤠 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.
import type { APIRoute } from 'astro';
-import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
+import { client, getOidcConfig } 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';
@@ -20,77 +25,75 @@ export const GET: APIRoute = async (context) => {
try {
console.log('🤠 Heady OIDC Callback received');
- // Extract parameters from callback
- const code = url.searchParams.get('code');
- const state = url.searchParams.get('state');
+ // Authentik-side error.
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)}`);
}
- // 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.
+ // Pull the PKCE verifier + state + nonce we stashed during /login.
const stateData = await consumeOidcState(context);
if (!stateData) {
console.error('❌ Missing or invalid state cookie');
return redirect('/login?error=invalid_state');
}
- // 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 cookie retrieved');
- console.log(`✓ State validated and PKCE verifier retrieved`);
-
- // Load Authentik configuration
const config = loadAuthentikConfig();
+ const oidcConfig = await getOidcConfig(config);
- // Create OIDC client
- const oidcClient = new AuthentikOIDCClient(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 } : {}),
+ });
- // Exchange authorization code for tokens
- console.log(' → Exchanging code for tokens...');
- const tokens = await oidcClient.exchangeCodeForTokens(
- code,
- stateData.codeVerifier,
- );
+ const idTokenClaims = tokens.claims();
+ if (!idTokenClaims) {
+ throw new Error('Token response missing verified ID token claims');
+ }
console.log(
- `✓ Tokens received (access token: ${tokens.access_token.substring(0, 20)}...)`,
+ `✓ Tokens received and ID token verified (sub: ${idTokenClaims.sub})`,
);
- // Fetch user information from Authentik
- console.log(' → Fetching user information...');
- const userInfo = await oidcClient.fetchUserInfo(tokens.access_token);
+ // 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[])
+ : [],
+ };
console.log(
`✓ User info received: ${userInfo.email} (${userInfo.groups.length} groups)`,
);
- // Map Authentik groups to Heady role
+ // Map Authentik groups → 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,
@@ -99,14 +102,9 @@ export const GET: APIRoute = async (context) => {
picture: userInfo.picture,
groups: userInfo.groups,
capabilities: roleMapping.role_mapping.capabilities,
- 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
- },
+ session: { session_id: '', expires_at: '', last_activity: '' },
};
- // Create secure session
console.log(' → Creating session...');
const sessionMgr = getSessionManager();
const sessionResult = await sessionMgr.createSession(context, sessionUser);
@@ -114,19 +112,10 @@ export const GET: APIRoute = async (context) => {
console.log(
`✓ Session created: ${sessionResult.session_id.substring(0, 16)}...`,
);
- 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}`);
@@ -138,16 +127,19 @@ export const GET: APIRoute = async (context) => {
}
}
- // 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.
+ // Map known error shapes to safe codes — never reflect error messages.
let errorCode = 'authentication_failed';
if (error instanceof Error) {
const msg = error.message.toLowerCase();
- if (msg.includes('token exchange')) errorCode = 'token_exchange_failed';
- else if (msg.includes('user info')) errorCode = 'user_info_failed';
+ 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';
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 b0300b8..c342272 100644
--- a/src/pages/api/auth/login.ts
+++ b/src/pages/api/auth/login.ts
@@ -1,12 +1,10 @@
-// 🤠 Heady OIDC Login Endpoint - Initiate Authentik Authentication
-
-/**
- * Initiates OIDC authentication flow with Authentik
- * Creates authorization URL and redirects user to Authentik login
- */
+// 🤠 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.
import type { APIRoute } from 'astro';
-import { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
+import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
import { storeOidcState } from '../../../lib/auth/oidc-state.js';
import {
loadAuthentikConfig,
@@ -21,10 +19,8 @@ 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(
@@ -32,10 +28,7 @@ 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' } },
);
}
@@ -45,31 +38,34 @@ export const GET: APIRoute = async (context) => {
console.log(` Redirect URI: ${config.redirectUri}`);
console.log(` Scopes: ${config.scopes.join(', ')}`);
- // Create OIDC client
- const oidcClient = new AuthentikOIDCClient(config);
+ // Discover the provider once, then cache.
+ const oidcConfig = await getOidcConfig(config);
- // Generate PKCE challenge
- const pkce = await oidcClient.generatePKCE();
+ // 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 state parameter
- const state = oidcClient.generateState();
+ // 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 });
- // Store {state, codeVerifier} in a signed cookie so the callback can
- // retrieve them regardless of which worker handles it.
- await storeOidcState(context, {
+ const authUrl = client.buildAuthorizationUrl(oidcConfig, {
+ redirect_uri: config.redirectUri,
+ scope: config.scopes.join(' '),
state,
- codeVerifier: pkce.codeVerifier,
+ nonce,
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
});
- // Build authorization URL
- const authUrl = oidcClient.buildAuthorizationUrl(state, pkce);
+ console.log(`✓ Redirecting to authorization endpoint`);
+ console.log(` State: ${state.slice(0, 10)}… Nonce: ${nonce.slice(0, 10)}…`);
- 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);
+ return redirect(authUrl.toString(), 302);
} catch (error) {
console.error('❌ OIDC login error:', error);
@@ -84,10 +80,7 @@ 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 0f253d9..4c608b2 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 { AuthentikOIDCClient } from '../../../lib/auth/oidc-client.js';
+import { client, getOidcConfig } from '../../../lib/auth/oidc-client.js';
import { getSessionManager } from '../../../lib/auth/session-manager.js';
import { loadAuthentikConfig } from '../../../lib/config/authentik.js';
@@ -43,28 +43,19 @@ export const POST: APIRoute = async ({ request, url, cookies }) => {
console.log(' → Initiating full OIDC logout with Authentik');
try {
- // Load configuration and create OIDC client for logout URL
+ // 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).
const config = loadAuthentikConfig();
- 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`,
- );
+ const oidcConfig = await getOidcConfig(config);
+ const logoutUrl = client.buildEndSessionUrl(oidcConfig, {
+ post_logout_redirect_uri: `${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,
- },
+ headers: { Location: logoutUrl.toString() },
});
} catch (error) {
console.warn(
diff --git a/src/pages/api/auth/status.ts b/src/pages/api/auth/status.ts
index 69414be..1970fd8 100644
--- a/src/pages/api/auth/status.ts
+++ b/src/pages/api/auth/status.ts
@@ -6,10 +6,7 @@
*/
import type { APIRoute } from 'astro';
-import {
- getActiveSessionCount,
- getSessionManager,
-} from '../../../lib/auth/session-manager.js';
+import { getSessionManager } from '../../../lib/auth/session-manager.js';
import {
getRoleMappingConfig,
loadAuthentikConfig,
@@ -104,7 +101,7 @@ export const GET: APIRoute = async ({ url, cookies }) => {
}
// Get session statistics (simplified for now)
- const activeSessionCount = getActiveSessionCount();
+ const activeSessionCount = await sessionMgr.getActiveCount();
const detailedStatus = {
...basicStatus,
diff --git a/src/pages/dns.astro b/src/pages/dns.astro
index cd68d35..6122f18 100644
--- a/src/pages/dns.astro
+++ b/src/pages/dns.astro
@@ -1,694 +1,30 @@
---
-import { getCollection } from 'astro:content';
-// Heady DNS Management - Alpine.js/Astro DNS Configuration 🤠
-import Layout from '../layouts/Layout.astro';
+// 🌐 Heady DNS — Astro shell, React island.
-// Mock DNS configuration data (in production, this would come from Headscale config)
-const dnsConfig = {
- // Network prefixes from Headscale
+import { DnsPage } from '@/components/dns/DnsPage';
+import Layout from '@/layouts/Layout.astro';
+
+// Sample DNS config until /api/dns is wired to Headscale.
+const initial = {
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.company.com': ['10.0.0.1', '10.0.0.2'],
+ 'corp.example.com': ['10.0.0.1', '10.0.0.2'],
'internal.dev': ['192.168.1.1'],
},
-
- // Search domains
- searchDomains: ['company.com', 'dev.local', 'staging.local'],
-
- // Override local DNS
+ searchDomains: ['example.com', 'dev.local', 'staging.local'],
overrideDns: true,
-
- // Extra DNS records
extraRecords: [
- { 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' },
+ { 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' },
],
-
- // 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 9f7c379..400172c 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -1,373 +1,40 @@
---
-import { getCollection } from 'astro:content';
-import AuthenticatedLayout from '../components/auth/AuthenticatedLayout.astro';
-// Heady Dashboard - Alpine.js/Astro Homepage 🤠
-import Layout from '../layouts/Layout.astro';
+// 🤠 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';
-// Fetch live data using Astro content collections
const allMachines = await getCollection('machines');
-const activeSessions = await getCollection(
+const allSessions = 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(),
- ),
-);
-// Calculate dashboard stats
-const onlineMachines = allMachines.filter((m) => m.data.online).length;
-const totalMachines = allMachines.length;
-const activeSessionCount = activeSessions.length;
+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,
+}));
-// System health indicators
-const systemHealth = {
- headscale: true, // Would be determined by actual health checks
- database: true,
- oidc: true,
- remoteAccess: true,
-};
+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,
+}));
---
-
-
-
-
-
-
-
-
-
- 🤠
- 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 4163a8b..88f5a35 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 6ed5bb1..b40f2af 100644
--- a/src/pages/settings.astro
+++ b/src/pages/settings.astro
@@ -1,933 +1,99 @@
---
-import { getCollection } from 'astro:content';
-// Heady Settings - Alpine.js/Astro Settings & Configuration 🤠
-import Layout from '../layouts/Layout.astro';
+// ⚙️ 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.
-// Mock settings data (in production, this would come from Headscale config)
-const settingsData = {
- // Server configuration
+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 = {
server: {
- 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',
+ version: HEADSCALE_PLACEHOLDER,
+ url: HEADSCALE_PLACEHOLDER,
+ publicUrl: authentik.redirectUri.replace('/api/auth/callback', ''),
+ grpcListenAddr: HEADSCALE_PLACEHOLDER,
+ metricsListenAddr: HEADSCALE_PLACEHOLDER,
},
-
- // Configuration permissions
- config: {
- writable: true, // Can modify configuration
- readable: true, // Can read configuration
- },
-
- // OIDC configuration
oidc: {
enabled: true,
- 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',
+ 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,
+ ],
},
-
- // Database configuration
database: {
- type: 'sqlite',
- sqlite: {
- path: '/var/lib/headscale/db.sqlite',
- },
+ type: HEADSCALE_PLACEHOLDER,
+ sqlite: { path: HEADSCALE_PLACEHOLDER },
},
-
- // TLS configuration
tls: {
letsencrypt: {
- hostname: 'vpn.company.com',
- listen: ':https',
- challengeType: 'TLS-ALPN-01',
+ hostname: HEADSCALE_PLACEHOLDER,
+ challengeType: HEADSCALE_PLACEHOLDER,
},
},
-
- // Log configuration
log: {
- level: 'info',
+ level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
format: 'text',
},
};
-// 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',
- },
- },
-];
+// 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[] = [];
-// 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 4107e27..8d922c1 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 b5c61c9..6df7077 100644
--- a/src/styles/global.css
+++ b/src/styles/global.css
@@ -1,3 +1,75 @@
@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 684a316..b095d1e 100644
--- a/tailwind.config.mjs
+++ b/tailwind.config.mjs
@@ -1,15 +1,59 @@
/** @type {import('tailwindcss').Config} */
+import tailwindcssAnimate from 'tailwindcss-animate';
+
export default {
+ darkMode: 'class',
content: [
- './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
+ './src/**/*.{astro,html,js,jsx,md,mdx,ts,tsx}',
'./src/pages/**/*.astro',
'./src/layouts/**/*.astro',
- './src/components/**/*.astro',
+ './src/components/**/*.{astro,tsx}',
],
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: {
- // Heady brand 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: {
50: '#f0f9ff',
100: '#e0f2fe',
@@ -23,21 +67,30 @@ 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',
- 'Roboto Mono',
- 'monospace',
- ],
+ 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' },
+ },
},
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: [
- // Add any Tailwind plugins you need
- ],
+ plugins: [tailwindcssAnimate],
};
diff --git a/tsconfig.json b/tsconfig.json
index 3fc47aa..27a93b5 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -21,9 +21,11 @@
"allowJs": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
+ "baseUrl": ".",
"paths": {
"~/*": ["./app/*"],
- "~server/*": ["./server/*"]
+ "~server/*": ["./server/*"],
+ "@/*": ["./src/*"]
},
// Vite takes care of building everything, not tsc.