From 09b1bae157cbdcc0eddf15e460d4fc59870028c5 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 20 Aug 2026 10:25:35 -0600 Subject: [PATCH] ui: shadcn-ui + React island shells for pages, Redis session store Rewrite the dashboard, machines, users, dns, and settings pages as thin Astro shells that mount a matching React island (Dashboard, MachinesPage, UsersPage, DnsPage, SettingsPage) built on the shadcn-ui component library. Alpine.js templates in those pages are replaced wholesale; Alpine still ships as the runtime for the ACL editor's dependencies. - src/components/ui/*: shadcn primitives (button, card, tabs, select, dropdown-menu, avatar, badge, input, switch, separator) - src/components/{dashboard,dns,machines,settings,users}/*: page-level React shells that consume server props from the Astro parent - src/components/shell/AppShell.tsx: shared chrome (nav, user menu) - src/lib/utils.ts: shadcn's cn() helper - src/lib/auth/session-manager.ts: pluggable SessionStore interface with Redis (via REDIS_URL) or in-memory backends; both honor absolute expiration via TTL. In-memory logs a warning that sessions vanish on restart. - src/lib/auth/oidc-client.ts, oidc-state.ts: shrink OIDC handlers now that PKCE + nonce state travels in a signed JWT cookie - src/pages/api/auth/*: match the simplified handlers - src/styles/global.css, tailwind.config.mjs, tsconfig.json: shadcn design tokens and the '@/*' path alias - docker-compose.local.yml: local dev tweaks - package.json, pnpm-lock.yaml: shadcn + Radix + ioredis + zod --- docker-compose.local.yml | 4 + package.json | 11 + pnpm-lock.yaml | 1029 +++++++++++++++++++++- src/components/dashboard/Dashboard.tsx | 406 +++++++++ src/components/dns/DnsPage.tsx | 512 +++++++++++ src/components/machines/MachinesPage.tsx | 356 ++++++++ src/components/settings/SettingsPage.tsx | 648 ++++++++++++++ src/components/shell/AppShell.tsx | 317 +++++++ src/components/ui/avatar.tsx | 47 + src/components/ui/badge.tsx | 37 + src/components/ui/button.tsx | 53 ++ src/components/ui/card.tsx | 82 ++ src/components/ui/dropdown-menu.tsx | 143 +++ src/components/ui/input.tsx | 21 + src/components/ui/select.tsx | 147 ++++ src/components/ui/separator.tsx | 28 + src/components/ui/switch.tsx | 26 + src/components/ui/tabs.tsx | 52 ++ src/components/users/UsersPage.tsx | 303 +++++++ src/layouts/Layout.astro | 15 +- src/lib/auth/oidc-client.ts | 347 +------- src/lib/auth/oidc-state.ts | 10 +- src/lib/auth/session-manager.ts | 420 ++++++--- src/lib/utils.ts | 10 + src/pages/api/auth/callback.ts | 132 ++- src/pages/api/auth/login.ts | 63 +- src/pages/api/auth/logout.ts | 27 +- src/pages/api/auth/status.ts | 7 +- src/pages/dns.astro | 694 +-------------- src/pages/index.astro | 393 +-------- src/pages/login.astro | 2 +- src/pages/machines.astro | 699 +-------------- src/pages/settings.astro | 980 ++------------------- src/pages/terminal.astro | 2 +- src/pages/users.astro | 760 +--------------- src/styles/global.css | 72 ++ tailwind.config.mjs | 79 +- tsconfig.json | 4 +- 38 files changed, 4966 insertions(+), 3972 deletions(-) create mode 100644 src/components/dashboard/Dashboard.tsx create mode 100644 src/components/dns/DnsPage.tsx create mode 100644 src/components/machines/MachinesPage.tsx create mode 100644 src/components/settings/SettingsPage.tsx create mode 100644 src/components/shell/AppShell.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/select.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/switch.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/users/UsersPage.tsx create mode 100644 src/lib/utils.ts 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 ─────────────────────────────── */} +
+
+

+ Welcome back, {user.name.split(' ')[0]} +

+

+ Strategic VPN management that's actually pleasant to use. +

+
+
+ + +
+
+ + {/* ─── 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 +
+ +
+ + {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) => ( + + ))} + + + ); +} + +/* ────────────────────────────────────── 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. +

+
+
+ + +
+
+ + {/* 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. + + + +
+ + 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} + +
  • + ))} +
+
+ setDraft(e.target.value)} + placeholder={placeholder} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + add(); + } + }} + disabled={disabled} + /> + +
+
+
+ ); +} + +/* ───── 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(', ')} +

    +
    + +
  • + ))} +
+ )} +
+ setDomain(e.target.value)} + placeholder="corp.example.com" + disabled={disabled} + /> + setServers(e.target.value)} + placeholder="10.0.0.1, 10.0.0.2" + disabled={disabled} + /> + +
+
+
+ ); +} + +/* ───── 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. +

+ ) : ( +
+ + + + + + + + + + {records.map((r, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: stable position + + + + + + + ))} + +
NameTypeValue +
{r.name} + + {r.type} + + + {r.value} + + +
+
+ )} +
+ setName(e.target.value)} + placeholder="api.heady.local" + disabled={disabled} + /> + + setValue(e.target.value)} + placeholder="100.64.0.10" + disabled={disabled} + /> + +
+
+
+ ); +} 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 ( +
+
+
+

Machines

+

+ Every device connected to your Headscale tailnet. +

+
+
+ + +
+
+ + {/* Stat strip */} +
+ } /> + } + /> + } + /> + 0 ? 'amber' : 'muted'} + icon={} + /> +
+ + {/* Filter bar */} + + +
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ + +
+
+ + {/* 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 ( +
+ + + + + + + + + + + + + {machines.map((m) => ( + + + + + + + + + + ))} + +
StatusNameIPOSUserTags +
+ + {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 ( +
+
+

Settings

+

+ Configure your Heady deployment, manage pre-auth keys, and review + identity restrictions. +

+
+ + {/* 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. + +
+ +
+ + {authKeys.length === 0 ? ( +
+ No auth keys yet. +
+ ) : ( +
+ + + + + + + + + + + + {authKeys.map((k) => { + const expired = new Date(k.expiration).getTime() < now; + return ( + + + + + + + + + ); + })} + +
KeyUserFlagsExpiresStatus +
+ {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 ( +
+ + {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. + + + + + + + + + + + + + + + + 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 + + +
+ + {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.name} + + {user.email} + +
+
+ + + + + Settings + + + + + + Users + + + + + + Sign out + +
+
+ ); +} + +/* ─────────────────────────────────────────── auth states ─────── */ + +function AuthLoading() { + return ( +
+ +

Loading Heady…

+
+ ); +} + +function AuthRequired() { + return ( +
+ +

Authentication required

+

+ Sign in with Authentik to manage your Headscale deployment. +

+ +
+ ); +} + +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}. +

+
+ + +
+
+ ); +} 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 ( +
+
+
+

Users

+

+ People in your tailnet, with roles mapped from Authentik groups. +

+
+ +
+ + {/* 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" + /> +
+ +
+
+ + {/* 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 ? ( + + ) : ( +
    - - \ 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. -

    -
    - - -
    -
    -
    - 🖥️ -
    -
    Server
    -
    -
    -
    -
    - -
    -
    - 🔐 -
    -
    OIDC
    -
    -
    -
    -
    -
    - -
    -
    - 🗝️ -
    -
    Auth Keys
    -
    -
    -
    -
    -
    - - -
    - -
    - - -
    - - -
    -
    - - -
    -

    🖥️ Server Information

    - -
    -

    Configuration

    -
    -
    - Version: - -
    -
    - URL: - -
    -
    - Public URL: - -
    -
    - gRPC Listen: - -
    -
    -
    - -
    -

    Database

    -
    -
    - Type: - -
    -
    - Path: - -
    -
    -
    -
    - - -
    -

    🚀 Quick Actions

    - -
    - - - - - -
    -
    -
    -
    - - -
    -
    -
    -

    🗝️ Pre-Authentication Keys

    -

    - Pre-auth keys allow devices to join your network without manual approval. - - Learn more - -

    -
    - - -
    - - -
    - - - - - -
    - - -
    - - - -
    -
    - - -
    -

    🔐 OIDC Configuration

    - -
    - - -
    -
    -

    Authentication Provider

    -
    -
    - OIDC Enabled -
    -
    -
    -
    - -
    - - -
    - -
    - - -
    -
    -
    -
    - - -
    -
    -

    Access Control

    -
    -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    -
    -
    -
    -
    -
    - - -
    -

    🛡️ 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

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