diff --git a/.attic/container-new.png b/.attic/container-new.png new file mode 100644 index 00000000..dd251717 Binary files /dev/null and b/.attic/container-new.png differ diff --git a/.attic/group-new.png b/.attic/group-new.png new file mode 100644 index 00000000..92f7af3c Binary files /dev/null and b/.attic/group-new.png differ diff --git a/.attic/nodeimport.png b/.attic/nodeimport.png new file mode 100644 index 00000000..db51bf58 Binary files /dev/null and b/.attic/nodeimport.png differ diff --git a/.gitignore b/.gitignore index 37d7e734..45adf944 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules .env +.tmp-verify/ diff --git a/create-a-container/client/.gitignore b/create-a-container/client/.gitignore new file mode 100644 index 00000000..f523d209 --- /dev/null +++ b/create-a-container/client/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +dist-ssr +*.local +.vite +*.log +.DS_Store +*.tsbuildinfo diff --git a/create-a-container/client/README.md b/create-a-container/client/README.md new file mode 100644 index 00000000..6296fa2f --- /dev/null +++ b/create-a-container/client/README.md @@ -0,0 +1,23 @@ +# Manager Client + +Vite + React 19 + TypeScript SPA for the Create-a-Container manager. Styles via Tailwind 4 and `@mieweb/ui` (BlueHive brand). + +## Develop + +```bash +npm install +npm run dev # http://localhost:5173 (proxies /api/* to Express; other routes are SPA-handled) +``` + +Express must be running on `http://localhost:3000` (or set `VITE_API_TARGET`). + +## Build + +```bash +npm run build # outputs to dist/, served by Express in production +``` + +## Layout + +- `src/app/` — router, layouts, shell +- `src/styles/` — Tailwind + `@mieweb/ui` brand entry diff --git a/create-a-container/client/index.html b/create-a-container/client/index.html new file mode 100644 index 00000000..31fb436c --- /dev/null +++ b/create-a-container/client/index.html @@ -0,0 +1,14 @@ + + + + + + + + MIE Container Manager + + +
+ + + diff --git a/create-a-container/client/package-lock.json b/create-a-container/client/package-lock.json new file mode 100644 index 00000000..1170e8f9 --- /dev/null +++ b/create-a-container/client/package-lock.json @@ -0,0 +1,2694 @@ +{ + "name": "@mieweb/create-a-container-client", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mieweb/create-a-container-client", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@mieweb/ui": "latest", + "@tanstack/react-query": "^5.62.0", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-router": "^7.1.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mieweb/ui": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@mieweb/ui/-/ui-0.6.1.tgz", + "integrity": "sha512-M1zHSukvCs9fTXTa/fZfHKueWOhOv8oomq0Mic9QhXcVMkN6i+6/JQdboEEJRtQYUhxGwNoTFbYIzMpbODvoXg==", + "hasInstallScript": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@swc/helpers": "^0.5.19", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "google-libphonenumber": "^3.2.44", + "lucide-react": "^0.562.0", + "luxon": "^3.7.2", + "tailwind-merge": "^2.6.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@esheet/builder": ">=0.0.2", + "@esheet/renderer": ">=0.0.2", + "@ozwell/react": ">=0.1.0", + "ag-grid-community": ">=32.0.0", + "ag-grid-react": ">=32.0.0", + "datavis-ace": "=4.0.0-PRE.2", + "react": ">=18.0.0", + "react-dom": ">=18.0.0", + "wavesurfer.js": ">=7.0.0" + }, + "peerDependenciesMeta": { + "@esheet/builder": { + "optional": true + }, + "@esheet/renderer": { + "optional": true + }, + "@ozwell/react": { + "optional": true + }, + "ag-grid-community": { + "optional": true + }, + "ag-grid-react": { + "optional": true + }, + "datavis-ace": { + "optional": true + }, + "react": { + "optional": false + }, + "react-dom": { + "optional": false + }, + "wavesurfer.js": { + "optional": true + } + } + }, + "node_modules/@mieweb/ui/node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.10.tgz", + "integrity": "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.10.tgz", + "integrity": "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.356", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", + "integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/google-libphonenumber": { + "version": "3.2.44", + "resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.44.tgz", + "integrity": "sha512-9p2TghluF2LTChFMLWsDRD5N78SZDsILdUk4gyqYxBXluCyxoPiOq+Fqt7DKM+LUd33+OgRkdrc+cPR93AypCQ==", + "license": "(MIT AND Apache-2.0)", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-hook-form": { + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/create-a-container/client/package.json b/create-a-container/client/package.json new file mode 100644 index 00000000..9dd8cd9c --- /dev/null +++ b/create-a-container/client/package.json @@ -0,0 +1,34 @@ +{ + "name": "@mieweb/create-a-container-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "tsc -b --noEmit", + "type-check": "tsc -b --noEmit" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.1", + "@mieweb/ui": "latest", + "@tanstack/react-query": "^5.62.0", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.0", + "react-router": "^7.1.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/create-a-container/client/src/app/AppLayout.tsx b/create-a-container/client/src/app/AppLayout.tsx new file mode 100644 index 00000000..c05f2035 --- /dev/null +++ b/create-a-container/client/src/app/AppLayout.tsx @@ -0,0 +1,21 @@ +import { Outlet } from 'react-router'; +import { Sidebar, CommandPalette } from '@mieweb/ui'; +import { AppSidebar } from './Sidebar'; +import { AppTopHeader } from './Header'; + +export function AppLayout() { + return ( +
+ + + +
+ +
+ +
+
+ +
+ ); +} diff --git a/create-a-container/client/src/app/AuthLayout.tsx b/create-a-container/client/src/app/AuthLayout.tsx new file mode 100644 index 00000000..367db4da --- /dev/null +++ b/create-a-container/client/src/app/AuthLayout.tsx @@ -0,0 +1,96 @@ +import { Outlet } from 'react-router'; +import { Boxes, ShieldCheck, Zap, Activity } from 'lucide-react'; + +const features = [ + { + icon: Boxes, + title: 'Provision on demand', + body: 'Spin up isolated containers across your fleet in seconds.', + }, + { + icon: ShieldCheck, + title: 'Secure by default', + body: 'Push-approved sign-ins, scoped API keys, audited every step.', + }, + { + icon: Activity, + title: 'Live visibility', + body: 'Monitor nodes, jobs, and services from a single console.', + }, +]; + +export function AuthLayout() { + const year = new Date().getFullYear(); + return ( +
+ {/* Brand / marketing panel */} + + + {/* Form panel */} +
+ {/* Mobile brand header */} +
+ + + + Container Manager +
+ +
+ +
+
+
+ ); +} diff --git a/create-a-container/client/src/app/Header.tsx b/create-a-container/client/src/app/Header.tsx new file mode 100644 index 00000000..2f5f81d8 --- /dev/null +++ b/create-a-container/client/src/app/Header.tsx @@ -0,0 +1,103 @@ +import { + AppHeader, + AppHeaderSection, + AppHeaderActions, + AppHeaderIconButton, + AppHeaderUserMenu, + Dropdown, + DropdownContent, + DropdownHeader, + DropdownItem, + DropdownSeparator, + SidebarMobileToggle, + useCommandPalette, + useThemeContext, +} from '@mieweb/ui'; +import { LogOut, Moon, Search, Settings, Sun } from 'lucide-react'; +import { useNavigate } from 'react-router'; +import { useLogoutMutation, useSession } from '@/lib/auth'; + +function initialsOf(name: string | undefined) { + if (!name) return '?'; + const parts = name.split(/[.\s_-]+/).filter(Boolean); + if (parts.length === 0) return name.slice(0, 2).toUpperCase(); + return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase(); +} + +export function AppTopHeader() { + const { data: session } = useSession(); + const { resolvedTheme, setTheme } = useThemeContext(); + const palette = useCommandPalette(); + const logout = useLogoutMutation(); + const navigate = useNavigate(); + + const isDark = resolvedTheme === 'dark'; + const isAdmin = !!session?.isAdmin; + const userName = session?.user || 'Account'; + const roleLabel = isAdmin ? 'Administrator' : 'User'; + const initials = initialsOf(session?.user); + + return ( + + + + {/* Sidebar already shows the brand on desktop; only repeat it on mobile + where the sidebar is collapsed off-canvas. AppHeaderBrand itself + is hidden below md by the library, so render a plain span. */} + Container Manager + + + + } + label="Search (⌘K)" + onClick={palette.open} + /> + : } + label={isDark ? 'Switch to light theme' : 'Switch to dark theme'} + onClick={() => setTheme(isDark ? 'light' : 'dark')} + /> + + } + > + + + + + ); +} diff --git a/create-a-container/client/src/app/RequireAuth.tsx b/create-a-container/client/src/app/RequireAuth.tsx new file mode 100644 index 00000000..c022870c --- /dev/null +++ b/create-a-container/client/src/app/RequireAuth.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; +import { Navigate, Outlet, useLocation } from 'react-router'; +import { Spinner } from '@mieweb/ui'; +import { useSession } from '@/lib/auth'; +import { setOnUnauthorized } from '@/lib/api'; + +/** + * Guards authenticated routes. Redirects to /login with the originating + * path stored on history state so the login flow can return the user. + */ +export function RequireAuth() { + const { data: session, isLoading, isError } = useSession(); + const location = useLocation(); + const [unauthorizedTrigger, setUnauthorizedTrigger] = useState(0); + + useEffect(() => { + setOnUnauthorized(() => setUnauthorizedTrigger((n) => n + 1)); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!session || isError || unauthorizedTrigger > 0) { + const redirect = encodeURIComponent(location.pathname + location.search); + return ; + } + + return ; +} diff --git a/create-a-container/client/src/app/Sidebar.tsx b/create-a-container/client/src/app/Sidebar.tsx new file mode 100644 index 00000000..7a99859a --- /dev/null +++ b/create-a-container/client/src/app/Sidebar.tsx @@ -0,0 +1,142 @@ +import type { ReactNode } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { + SidebarNav, + SidebarNavItem, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarToggle, + useSidebar, +} from '@mieweb/ui'; +import { + Box, + Building2, + ExternalLink, + Globe, + KeyRound, + Settings, + ShieldCheck, + Users, + UsersRound, +} from 'lucide-react'; +import { useSession } from '@/lib/auth'; + +function initialsOf(name: string | undefined) { + if (!name) return '?'; + const parts = name.split(/[.\s_-]+/).filter(Boolean); + if (parts.length === 0) return name.slice(0, 2).toUpperCase(); + return (parts[0][0] + (parts[1]?.[0] || '')).toUpperCase(); +} + +interface NavLink { + to: string; + label: string; + icon: ReactNode; + adminOnly?: boolean; + /** Match this prefix to mark active (defaults to exact `to`). */ + match?: string; +} + +const PRIMARY: NavLink[] = [ + { to: '/sites', label: 'Sites', icon: , match: '/sites' }, +]; + +const ADMIN: NavLink[] = [ + { to: '/users', label: 'Users', icon: , adminOnly: true }, + { to: '/groups', label: 'Groups', icon: , adminOnly: true }, + { + to: '/external-domains', + label: 'External Domains', + icon: , + adminOnly: true, + }, + { to: '/apikeys', label: 'API Keys', icon: }, + { to: '/settings', label: 'Settings', icon: , adminOnly: true }, +]; + +export function AppSidebar() { + const navigate = useNavigate(); + const location = useLocation(); + const { data: session } = useSession(); + const { isCollapsed, isMobileViewport } = useSidebar(); + const isAdmin = !!session?.isAdmin; + const mfaAdminUrl = + isAdmin && session?.pushNotificationUrl ? `${session.pushNotificationUrl}/admin` : null; + + // Treat the sidebar as compact only on desktop collapse; on mobile the off-canvas + // panel is full-width and should always show labels. + const compact = isCollapsed && !isMobileViewport; + + const isActive = (link: NavLink) => { + const prefix = link.match ?? link.to; + return location.pathname === prefix || location.pathname.startsWith(`${prefix}/`); + }; + + const renderLink = (link: NavLink) => ( + navigate(link.to)} + /> + ); + + return ( + <> + +
+ + {!compact && ( + Container Manager + )} +
+
+ + {PRIMARY.map(renderLink)} + {mfaAdminUrl && ( + } + badge={compact ? undefined : + +
+ {!isMobileViewport && } + + {!compact && ( +
+
+ {session?.user || 'Account'} +
+
+ {isAdmin ? 'Administrator' : 'User'} +
+
+ )} +
+
+ + ); +} diff --git a/create-a-container/client/src/app/providers.tsx b/create-a-container/client/src/app/providers.tsx new file mode 100644 index 00000000..b517e80e --- /dev/null +++ b/create-a-container/client/src/app/providers.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { + ThemeProvider, + ToastProvider, + SidebarProvider, + CommandPaletteProvider, +} from '@mieweb/ui'; + +export function AppProviders({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} diff --git a/create-a-container/client/src/app/router.tsx b/create-a-container/client/src/app/router.tsx new file mode 100644 index 00000000..da315b10 --- /dev/null +++ b/create-a-container/client/src/app/router.tsx @@ -0,0 +1,86 @@ +import { createBrowserRouter, Navigate } from 'react-router'; +import { AppLayout } from './AppLayout'; +import { AuthLayout } from './AuthLayout'; +import { RequireAuth } from './RequireAuth'; +import { LoginPage } from '@/pages/auth/LoginPage'; +import { RegisterPage } from '@/pages/auth/RegisterPage'; +import { RegisterSuccessPage } from '@/pages/auth/RegisterSuccessPage'; +import { ResetPasswordRequestPage } from '@/pages/auth/ResetPasswordRequestPage'; +import { ResetPasswordPage } from '@/pages/auth/ResetPasswordPage'; +import { SitesListPage } from '@/pages/sites/SitesListPage'; +import { SiteFormPage } from '@/pages/sites/SiteFormPage'; +import { ContainersListPage } from '@/pages/containers/ContainersListPage'; +import { ContainerFormPage } from '@/pages/containers/ContainerFormPage'; +import { NodesListPage } from '@/pages/nodes/NodesListPage'; +import { NodeFormPage } from '@/pages/nodes/NodeFormPage'; +import { NodeImportPage } from '@/pages/nodes/NodeImportPage'; +import { ExternalDomainsListPage } from '@/pages/external-domains/ExternalDomainsListPage'; +import { ExternalDomainFormPage } from '@/pages/external-domains/ExternalDomainFormPage'; +import { JobDetailPage } from '@/pages/jobs/JobDetailPage'; +import { UsersListPage } from '@/pages/users/UsersListPage'; +import { UserFormPage } from '@/pages/users/UserFormPage'; +import { InviteUserPage } from '@/pages/users/InviteUserPage'; +import { GroupsListPage } from '@/pages/groups/GroupsListPage'; +import { GroupFormPage } from '@/pages/groups/GroupFormPage'; +import { ApiKeysListPage } from '@/pages/apikeys/ApiKeysListPage'; +import { SettingsPage } from '@/pages/settings/SettingsPage'; +import { NotFoundPage } from '@/pages/NotFoundPage'; + +export const router = createBrowserRouter([ + { + element: , + children: [ + { path: '/login', element: }, + { path: '/register', element: }, + { path: '/register/invite/:token', element: }, + { path: '/register/success', element: }, + { path: '/reset-password', element: }, + { path: '/reset-password/:token', element: }, + ], + }, + { + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + + { path: '/sites', element: }, + { path: '/sites/new', element: }, + { path: '/sites/:id/edit', element: }, + + { path: '/sites/:siteId/containers', element: }, + { path: '/sites/:siteId/containers/new', element: }, + { path: '/sites/:siteId/containers/:id/edit', element: }, + + { path: '/sites/:siteId/nodes', element: }, + { path: '/sites/:siteId/nodes/new', element: }, + { path: '/sites/:siteId/nodes/import', element: }, + { path: '/sites/:siteId/nodes/:id/edit', element: }, + + { path: '/external-domains', element: }, + { path: '/external-domains/new', element: }, + { path: '/external-domains/:id/edit', element: }, + + { path: '/jobs/:id', element: }, + + { path: '/users', element: }, + { path: '/users/new', element: }, + { path: '/users/invite', element: }, + { path: '/users/:uid/edit', element: }, + + { path: '/groups', element: }, + { path: '/groups/new', element: }, + { path: '/groups/:id/edit', element: }, + + { path: '/apikeys', element: }, + + { path: '/settings', element: }, + + { path: '*', element: }, + ], + }, + ], + }, +]); diff --git a/create-a-container/client/src/components/FormPageHeader.tsx b/create-a-container/client/src/components/FormPageHeader.tsx new file mode 100644 index 00000000..9c7e2d2a --- /dev/null +++ b/create-a-container/client/src/components/FormPageHeader.tsx @@ -0,0 +1,48 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router'; +import { ArrowLeft } from 'lucide-react'; + +export interface FormPageHeaderProps { + icon?: ReactNode; + title: string; + subtitle?: string; + description?: ReactNode; + backTo?: { label: string; to: string }; +} + +export function FormPageHeader({ icon, title, subtitle, description, backTo }: FormPageHeaderProps) { + return ( +
+ {backTo && ( + +
+ ); +} diff --git a/create-a-container/client/src/components/FormPageLayout.tsx b/create-a-container/client/src/components/FormPageLayout.tsx new file mode 100644 index 00000000..0e6065d4 --- /dev/null +++ b/create-a-container/client/src/components/FormPageLayout.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@mieweb/ui'; +import { FormPageHeader, type FormPageHeaderProps } from './FormPageHeader'; + +interface FormPageLayoutProps extends FormPageHeaderProps { + /** Optional supplementary content (tips, docs links) shown beneath the card. */ + aside?: ReactNode; + /** Card heading shown above the form fields. */ + cardTitle?: string; + /** Constrain the inner content width. Defaults to 2xl. */ + maxWidth?: 'xl' | '2xl' | '3xl' | '4xl'; + /** Form body (fields). */ + children: ReactNode; + /** Action row rendered in the card footer (typically Cancel + Submit buttons). */ + actions: ReactNode; +} + +const MAX_WIDTH_CLASS = { + xl: 'max-w-xl', + '2xl': 'max-w-2xl', + '3xl': 'max-w-3xl', + '4xl': 'max-w-4xl', +} as const; + +/** + * Centered single-column form scaffold for create/edit pages. + * Renders a polished hero, a flush-footer card, and optional supporting content beneath. + */ +export function FormPageLayout({ + icon, + title, + subtitle, + description, + aside, + backTo, + cardTitle, + maxWidth = '2xl', + children, + actions, +}: FormPageLayoutProps) { + return ( +
+ + + + {cardTitle && ( + + {cardTitle} + + )} + {children} + + {actions} + + + + {aside &&
{aside}
} +
+ ); +} diff --git a/create-a-container/client/src/lib/api.ts b/create-a-container/client/src/lib/api.ts new file mode 100644 index 00000000..bd1e06ce --- /dev/null +++ b/create-a-container/client/src/lib/api.ts @@ -0,0 +1,146 @@ +/** + * Typed JSON client for /api/v1. Handles: + * - cookies (session) + * - CSRF double-submit token (fetched on demand, cached, refreshed on 403) + * - `{ data }` / `{ error }` envelope unwrapping + * - 401 → optional auth-failure handler (router decides what to do) + */ + +export interface ApiErrorPayload { + code: string; + message: string; + fields?: Record; +} + +export class ApiError extends Error { + status: number; + code: string; + fields?: Record; + constructor(status: number, payload: ApiErrorPayload) { + super(payload.message || `Request failed (${status})`); + this.name = 'ApiError'; + this.status = status; + this.code = payload.code || 'unknown'; + this.fields = payload.fields; + } +} + +let csrfToken: string | null = null; +let onUnauthorized: (() => void) | null = null; + +export function setOnUnauthorized(fn: () => void) { + onUnauthorized = fn; +} + +async function fetchCsrfToken(): Promise { + const res = await fetch('/api/v1/csrf-token', { credentials: 'include' }); + if (!res.ok) throw new Error(`Failed to fetch CSRF token: ${res.status}`); + const body = (await res.json()) as { data: { csrfToken: string } }; + csrfToken = body.data.csrfToken; + return csrfToken; +} + +export function clearCsrfToken() { + csrfToken = null; +} + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + body?: unknown; + signal?: AbortSignal; + /** Pass the raw Response back instead of parsing JSON (for SSE). */ + raw?: boolean; + headers?: Record; +} + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +export async function apiRequest( + path: string, + options: RequestOptions = {}, +): Promise { + const method = options.method || 'GET'; + const isMutation = MUTATING_METHODS.has(method); + + if (isMutation && !csrfToken) { + await fetchCsrfToken(); + } + + const doFetch = async (): Promise => { + const headers: Record = { + Accept: 'application/json', + ...options.headers, + }; + if (options.body !== undefined && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + if (isMutation && csrfToken) headers['X-CSRF-Token'] = csrfToken; + + return fetch(path.startsWith('/') ? path : `/api/v1/${path}`, { + method, + credentials: 'include', + signal: options.signal, + headers, + body: + options.body === undefined + ? undefined + : options.body instanceof FormData + ? options.body + : JSON.stringify(options.body), + }); + }; + + let response = await doFetch(); + + // CSRF token may have rotated; retry once. + if (response.status === 403 && isMutation) { + const text = await response.clone().text(); + if (text.includes('csrf') || text.includes('CSRF')) { + await fetchCsrfToken(); + response = await doFetch(); + } + } + + if (response.status === 401) { + if (onUnauthorized) onUnauthorized(); + throw new ApiError(401, { code: 'unauthorized', message: 'Sign-in required' }); + } + + if (options.raw) return response as unknown as T; + + if (response.status === 204) return undefined as T; + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + if (!response.ok) { + throw new ApiError(response.status, { + code: 'http_error', + message: `HTTP ${response.status}`, + }); + } + return undefined as T; + } + + const body = (await response.json()) as + | { data: T; meta?: unknown } + | { error: ApiErrorPayload }; + + if (!response.ok || 'error' in body) { + const err = 'error' in body ? body.error : { code: 'unknown', message: 'Request failed' }; + throw new ApiError(response.status, err); + } + return body.data; +} + +export const api = { + get: (path: string, opts?: Omit) => + apiRequest(path, { ...opts, method: 'GET' }), + post: (path: string, body?: unknown, opts?: Omit) => + apiRequest(path, { ...opts, method: 'POST', body }), + put: (path: string, body?: unknown, opts?: Omit) => + apiRequest(path, { ...opts, method: 'PUT', body }), + patch: (path: string, body?: unknown, opts?: Omit) => + apiRequest(path, { ...opts, method: 'PATCH', body }), + delete: (path: string, opts?: Omit) => + apiRequest(path, { ...opts, method: 'DELETE' }), +}; diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts new file mode 100644 index 00000000..d1916cf3 --- /dev/null +++ b/create-a-container/client/src/lib/auth.ts @@ -0,0 +1,139 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { api, ApiError, clearCsrfToken } from './api'; + +export interface SessionUser { + user: string; + isAdmin: boolean; + /** Configured push-notification service URL (admins only, empty if unset). */ + pushNotificationUrl?: string; +} + +export interface ServerInfo { + status: string; + isDev: boolean; +} + +export const sessionKey = ['session'] as const; +export const serverInfoKey = ['server-info'] as const; + +export function useServerInfo() { + return useQuery({ + queryKey: serverInfoKey, + queryFn: () => api.get('/api/v1/health'), + staleTime: Infinity, + }); +} + +export function useSession() { + return useQuery({ + queryKey: sessionKey, + queryFn: async () => { + try { + return await api.get('/api/v1/session'); + } catch (err) { + if (err instanceof ApiError && err.status === 401) return null; + throw err; + } + }, + staleTime: 60_000, + refetchOnWindowFocus: true, + }); +} + +export interface LoginInput { + username: string; + password: string; + redirect?: string; +} + +export type LoginResult = + | { kind: 'logged-in'; user: string; isAdmin: boolean; redirect: string } + | { kind: '2fa'; challengeId: string }; + +interface LoginResponse { + user?: string; + isAdmin?: boolean; + redirect?: string; + challengeId?: string; + requires2FA?: boolean; +} + +export function useLoginMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (input) => { + const data = await api.post('/api/v1/auth/login', input); + if (data.requires2FA && data.challengeId) { + return { kind: '2fa', challengeId: data.challengeId }; + } + return { + kind: 'logged-in', + user: data.user || input.username, + isAdmin: !!data.isAdmin, + redirect: data.redirect || '/', + }; + }, + onSuccess: (result) => { + if (result.kind === 'logged-in') { + qc.setQueryData(sessionKey, { + user: result.user, + isAdmin: result.isAdmin, + }); + } + }, + }); +} + +export interface ChallengeStatus { + status: 'pending' | 'approved' | 'rejected' | 'timeout' | 'failed' | 'unregistered'; + user?: string; + isAdmin?: boolean; + redirect?: string; + message?: string; + registrationUrl?: string; +} + +export async function fetchChallenge(id: string): Promise { + return api.get(`/api/v1/auth/login/challenge/${encodeURIComponent(id)}`); +} + +export function useLogoutMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await api.post('/api/v1/auth/logout'); + }, + onSettled: () => { + clearCsrfToken(); + qc.setQueryData(sessionKey, null); + qc.clear(); + }, + }); +} + +export interface DevLoginInput { + role: 'admin' | 'user'; +} + +interface DevLoginResponse { + user: string; + isAdmin: boolean; + redirect?: string; +} + +/** + * One-click dev login (non-production only). The /api/v1/auth/dev endpoint + * returns 404 in production, so the UI gates this behind useServerInfo().isDev. + */ +export function useDevLoginMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input) => api.post('/api/v1/auth/dev', input), + onSuccess: (data) => { + qc.setQueryData(sessionKey, { + user: data.user, + isAdmin: data.isAdmin, + }); + }, + }); +} diff --git a/create-a-container/client/src/lib/queries.ts b/create-a-container/client/src/lib/queries.ts new file mode 100644 index 00000000..0d4a5e0e --- /dev/null +++ b/create-a-container/client/src/lib/queries.ts @@ -0,0 +1,91 @@ +/** + * Centralized query keys + fetcher functions for TanStack Query. + * Each `keys.x()` returns a stable tuple that callers pass to useQuery. + */ +import { api } from './api'; +import type { + ApiKey, + Container, + ContainerMetadata, + ContainerNewBootstrap, + ExternalDomain, + Group, + Job, + JobStatusRow, + Node, + AppSettings, + Site, + User, +} from './types'; + +export const keys = { + sites: () => ['sites'] as const, + site: (id: number | string) => ['sites', String(id)] as const, + nodes: (siteId: number | string) => ['sites', String(siteId), 'nodes'] as const, + node: (siteId: number | string, id: number | string) => + ['sites', String(siteId), 'nodes', String(id)] as const, + containers: (siteId: number | string) => ['sites', String(siteId), 'containers'] as const, + container: (siteId: number | string, id: number | string) => + ['sites', String(siteId), 'containers', String(id)] as const, + containerBootstrap: (siteId: number | string) => + ['sites', String(siteId), 'containers', 'new'] as const, + containerMetadata: (image: string) => ['container-metadata', image] as const, + externalDomains: () => ['external-domains'] as const, + externalDomain: (id: number | string) => ['external-domains', String(id)] as const, + users: () => ['users'] as const, + user: (uid: number | string) => ['users', String(uid)] as const, + groups: () => ['groups'] as const, + group: (id: number | string) => ['groups', String(id)] as const, + apikeys: () => ['apikeys'] as const, + settings: () => ['settings'] as const, + job: (id: number | string) => ['jobs', String(id)] as const, + jobStatuses: (id: number | string) => ['jobs', String(id), 'statuses'] as const, +}; + +export const queries = { + // Sites + listSites: () => api.get('/api/v1/sites'), + getSite: (id: number | string) => api.get(`/api/v1/sites/${id}`), + + // Nodes + listNodes: (siteId: number | string) => + api.get(`/api/v1/sites/${siteId}/nodes`), + getNode: (siteId: number | string, id: number | string) => + api.get(`/api/v1/sites/${siteId}/nodes/${id}`), + + // Containers + listContainers: (siteId: number | string) => + api.get(`/api/v1/sites/${siteId}/containers`), + getContainer: (siteId: number | string, id: number | string) => + api.get(`/api/v1/sites/${siteId}/containers/${id}`), + containerBootstrap: (siteId: number | string) => + api.get(`/api/v1/sites/${siteId}/containers/new`), + containerMetadata: (siteId: number | string, image: string) => + api.get( + `/api/v1/sites/${siteId}/containers/metadata?image=${encodeURIComponent(image)}`, + ), + + // External domains + listExternalDomains: () => api.get('/api/v1/external-domains'), + getExternalDomain: (id: number | string) => + api.get(`/api/v1/external-domains/${id}`), + + // Users + listUsers: () => api.get('/api/v1/users'), + getUser: (uid: number | string) => api.get(`/api/v1/users/${uid}`), + + // Groups + listGroups: () => api.get('/api/v1/groups'), + getGroup: (id: number | string) => api.get(`/api/v1/groups/${id}`), + + // API keys + listApiKeys: () => api.get('/api/v1/apikeys'), + + // Settings + getSettings: () => api.get('/api/v1/settings'), + + // Jobs + getJob: (id: number | string) => api.get(`/api/v1/jobs/${id}`), + getJobStatuses: (id: number | string, offset = 0, limit = 1000) => + api.get(`/api/v1/jobs/${id}/status?offset=${offset}&limit=${limit}`), +}; diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts new file mode 100644 index 00000000..f18eb549 --- /dev/null +++ b/create-a-container/client/src/lib/types.ts @@ -0,0 +1,169 @@ +/** + * Typed resource models matching /api/v1 response shapes. + * Keep in sync with the serializers in routers/api/v1/*. + */ + +export interface Site { + id: number; + name: string; + internalDomain: string; + dhcpRange: string | null; + subnetMask: string | null; + gateway: string | null; + dnsForwarders: string | null; + externalIp: string | null; + nodeCount?: number; +} + +export interface Node { + id: number; + name: string; + siteId: number; + ipv4Address: string | null; + apiUrl: string | null; + tokenId: string | null; + tlsVerify: boolean | null; + imageStorage: string; + volumeStorage: string; + networkBridge: string; + nvidiaAvailable: boolean; + hasSecret: boolean; +} + +export interface ExternalDomain { + id: number; + name: string; + acmeEmail: string | null; + acmeDirectoryUrl: string | null; + cloudflareApiEmail: string | null; + siteId: number | null; + site: { id: number; name: string } | null; + authServer: string | null; + hasCloudflareApiKey: boolean; +} + +export interface ServiceHttp { + id: number; + externalHostname: string; + externalDomainId: number; + backendProtocol: 'http' | 'https'; + authRequired: boolean; + domain?: string; +} +export interface ServiceTransport { + id: number; + protocol: 'tcp' | 'udp'; + externalPort: number; +} +export interface ServiceDns { + id: number; + recordType: string; + dnsName: string; +} +export interface ContainerService { + id: number; + type: 'http' | 'transport' | 'dns'; + internalPort: number; + httpService: ServiceHttp | null; + transportService: ServiceTransport | null; + dnsService: ServiceDns | null; +} + +export interface Container { + id: number; + containerId: number | null; + hostname: string; + ipv4Address: string | null; + macAddress: string | null; + status: string; + template: string | null; + creationJobId: number | null; + entrypoint: string | null; + environmentVars: Record; + nvidiaRequested: boolean; + sshPort: number | null; + sshHost: string | null; + httpEntries: { port: number; externalUrl: string | null }[]; + nodeName: string | null; + services: ContainerService[]; + createdAt: string; +} + +export interface ContainerCreateResult { + containerId: number; + jobId: number; + hostname: string; + status: string; +} + +export interface ContainerNewBootstrap { + siteId: number; + externalDomains: { id: number; name: string }[]; + nvidiaAvailable: boolean; +} + +export interface ContainerMetadata { + exposedPorts?: string[]; + entrypoint?: string[] | string; + cmd?: string[] | string; + env?: string[]; +} + +export interface Job { + id: number; + command: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +export interface JobStatusRow { + id: number; + jobId: number; + output: string; + createdAt: string; +} + +export interface User { + uidNumber: number; + uid: string; + givenName: string; + sn: string; + cn: string; + mail: string; + status: 'pending' | 'active' | 'disabled'; + groups?: { gidNumber: number; cn: string; isAdmin: boolean }[]; + isAdmin: boolean; + twoFactorWarning?: string; +} + +export interface Group { + gidNumber: number; + cn: string; + isAdmin: boolean; + userCount?: number; +} + +export interface ApiKey { + id: number; + keyPrefix: string; + description: string | null; + lastUsedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface ApiKeyCreated extends ApiKey { + key: string; + warning: string; +} + +export interface AppSettings { + pushNotificationUrl: string; + pushNotificationEnabled: boolean; + pushNotificationApiKey: string; + smtpUrl: string; + smtpNoreplyAddress: string; + defaultContainerEnvVars: { key: string; value: string; description?: string }[]; +} diff --git a/create-a-container/client/src/lib/useDocumentTitle.ts b/create-a-container/client/src/lib/useDocumentTitle.ts new file mode 100644 index 00000000..12c0efd8 --- /dev/null +++ b/create-a-container/client/src/lib/useDocumentTitle.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +const SUFFIX = 'Container Manager'; + +export function useDocumentTitle(title: string) { + useEffect(() => { + const previous = document.title; + document.title = title ? `${title} · ${SUFFIX}` : SUFFIX; + return () => { + document.title = previous; + }; + }, [title]); +} diff --git a/create-a-container/client/src/main.tsx b/create-a-container/client/src/main.tsx new file mode 100644 index 00000000..e95a07bf --- /dev/null +++ b/create-a-container/client/src/main.tsx @@ -0,0 +1,34 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from 'react-router'; +import { AppProviders } from './app/providers'; +import { router } from './app/router'; +import './styles/index.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (count, err) => { + const status = (err as { status?: number } | null)?.status; + if (status === 401 || status === 403 || status === 404) return false; + return count < 2; + }, + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + }, +}); + +const rootEl = document.getElementById('root'); +if (!rootEl) throw new Error('Missing #root element'); + +createRoot(rootEl).render( + + + + + + + , +); diff --git a/create-a-container/client/src/pages/NotFoundPage.tsx b/create-a-container/client/src/pages/NotFoundPage.tsx new file mode 100644 index 00000000..eecbacdc --- /dev/null +++ b/create-a-container/client/src/pages/NotFoundPage.tsx @@ -0,0 +1,12 @@ +import { useNavigate } from 'react-router'; +import { ErrorPage } from '@mieweb/ui'; + +export function NotFoundPage() { + const navigate = useNavigate(); + return ( + navigate('/sites') }} + /> + ); +} diff --git a/create-a-container/client/src/pages/apikeys/ApiKeysListPage.tsx b/create-a-container/client/src/pages/apikeys/ApiKeysListPage.tsx new file mode 100644 index 00000000..a8f54f2d --- /dev/null +++ b/create-a-container/client/src/pages/apikeys/ApiKeysListPage.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Button, + Input, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { KeyRound, Plus, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { ApiKey, ApiKeyCreated } from '@/lib/types'; + +export function ApiKeysListPage() { + const { data, isLoading, error } = useQuery({ queryKey: keys.apikeys(), queryFn: queries.listApiKeys }); + const qc = useQueryClient(); + const toast = useToast(); + const [creating, setCreating] = useState(false); + const [description, setDescription] = useState(''); + const [created, setCreated] = useState(null); + + const createMutation = useMutation({ + mutationFn: () => api.post('/api/v1/apikeys', { description }), + onSuccess: (key) => { + setCreated(key); + setDescription(''); + setCreating(false); + qc.invalidateQueries({ queryKey: keys.apikeys() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/apikeys/${id}`), + onSuccess: () => { + toast.success('API key revoked'); + qc.invalidateQueries({ queryKey: keys.apikeys() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ + !creating && ( + + ) + } + /> + + {created && ( + + +
+ {created.warning} +
+ + {created.key} + + +
+
+ { e.preventDefault(); setCreated(null); }}> + Dismiss + +
+
+
+
+ )} + + {creating && ( +
{ + e.preventDefault(); + createMutation.mutate(); + }} + className="flex flex-col gap-3 rounded-lg border border-(--color-border,#e5e7eb) p-4" + > + setDescription(e.target.value)} + required + /> +
+ + +
+
+ )} + + {error && {(error as ApiError).message}} + {isLoading &&
} + {data && ( + + + + Prefix + Description + Last used + Created + Actions + + + + {data.map((k: ApiKey) => ( + + {k.keyPrefix}… + {k.description || '—'} + {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleString() : 'Never'} + {new Date(k.createdAt).toLocaleDateString()} + + + + + ))} + +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/auth/LoginPage.tsx b/create-a-container/client/src/pages/auth/LoginPage.tsx new file mode 100644 index 00000000..e3ff69bd --- /dev/null +++ b/create-a-container/client/src/pages/auth/LoginPage.tsx @@ -0,0 +1,389 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Input, + Spinner, + usePrefersReducedMotion, +} from '@mieweb/ui'; +import { + ShieldCheck, + Smartphone, + AlertTriangle, + XCircle, + Eye, + EyeOff, + Lock, +} from 'lucide-react'; +import { useLoginMutation, useDevLoginMutation, useServerInfo, fetchChallenge, type ChallengeStatus } from '@/lib/auth'; +import { ApiError } from '@/lib/api'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; + +const schema = z.object({ + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), +}); +type FormData = z.infer; + +const POLL_INTERVAL_MS = 2000; +const POLL_MAX_MS = 5 * 60 * 1000; + +export function LoginPage() { + useDocumentTitle('Sign in'); + const navigate = useNavigate(); + const [params] = useSearchParams(); + const redirect = params.get('redirect') || '/'; + const login = useLoginMutation(); + const devLogin = useDevLoginMutation(); + const { data: serverInfo } = useServerInfo(); + const isDev = !!serverInfo?.isDev; + + const [challengeId, setChallengeId] = useState(null); + const [challenge, setChallenge] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [capsLock, setCapsLock] = useState(false); + const pollTimer = useRef(null); + const pollStart = useRef(0); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }); + + useEffect(() => { + return () => { + if (pollTimer.current) window.clearTimeout(pollTimer.current); + }; + }, []); + + function startPolling(id: string) { + pollStart.current = Date.now(); + const poll = async () => { + try { + const status = await fetchChallenge(id); + setChallenge(status); + if (status.status === 'approved') { + navigate(status.redirect && status.redirect !== '/' ? status.redirect : redirect, { + replace: true, + }); + return; + } + if ( + status.status === 'rejected' || + status.status === 'timeout' || + status.status === 'failed' || + status.status === 'unregistered' + ) { + return; + } + if (Date.now() - pollStart.current > POLL_MAX_MS) { + setChallenge({ status: 'timeout', message: 'Challenge expired' }); + return; + } + pollTimer.current = window.setTimeout(poll, POLL_INTERVAL_MS); + } catch (err) { + setChallenge({ + status: 'failed', + message: err instanceof ApiError ? err.message : 'Failed to check challenge', + }); + } + }; + poll(); + } + + const onSubmit = handleSubmit(async (values) => { + setChallenge(null); + setChallengeId(null); + try { + const result = await login.mutateAsync({ ...values, redirect }); + if (result.kind === 'logged-in') { + navigate(result.redirect && result.redirect !== '/' ? result.redirect : redirect, { + replace: true, + }); + } else { + setChallengeId(result.challengeId); + setChallenge({ status: 'pending' }); + startPolling(result.challengeId); + } + } catch { + /* error handled via login.error */ + } + }); + + const onDevLogin = async (role: 'admin' | 'user') => { + try { + await devLogin.mutateAsync({ role }); + navigate(redirect, { replace: true }); + } catch { + /* error handled via devLogin.error */ + } + }; + + const submissionError = + login.error && login.error.status !== 401 + ? login.error.message + : login.error?.status === 401 + ? 'Invalid username or password' + : null; + + if (challengeId && challenge) { + return setChallengeId(null)} />; + } + + const passwordField = register('password'); + + return ( +
+
+

+ Welcome back +

+

+ Sign in to your Container Manager account to continue. +

+
+ +
+ {submissionError && ( + + Sign in failed + {submissionError} + + )} + + + +
+ setCapsLock(e.getModifierState('CapsLock'))} + onKeyDown={(e) => setCapsLock(e.getModifierState('CapsLock'))} + onBlur={(e) => { + setCapsLock(false); + void passwordField.onBlur(e); + }} + /> + {capsLock && ( +

+

+ )} +
+ + + Forgot password? + +
+
+ + + +
+
+ + + Create an account + + + {isDev && ( +
+

+ Dev mode +

+
+ + +
+ {devLogin.error && ( +

+ {devLogin.error.message} +

+ )} +
+ )} +
+ +

+ Protected by push-approved sign-in.{' '} + +

+
+ ); +} + +function ChallengeStatusView({ + status, + onCancel, +}: { + status: ChallengeStatus; + onCancel: () => void; +}) { + const reduceMotion = usePrefersReducedMotion(); + if (status.status === 'pending') { + return ( +
+
+ {!reduceMotion && ( +
+ +
+

+ Approve sign-in on your device +

+

+ We sent a push notification to your registered device. Tap{' '} + Approve to + finish signing in. +

+
+ +
+ + Waiting for approval… +
+ + +
+ ); + } + + if (status.status === 'unregistered') { + return ( +
+
+ + + +

+ Device not registered +

+

+ No device is enrolled for push 2FA on this account. Contact an administrator to receive + an enrollment invite. +

+
+ +
+ ); + } + + return ( +
+
+ + + +

+ Sign-in not completed +

+

+ {status.message || `Status: ${status.status}`} +

+
+ +
+ ); +} diff --git a/create-a-container/client/src/pages/auth/RegisterPage.tsx b/create-a-container/client/src/pages/auth/RegisterPage.tsx new file mode 100644 index 00000000..90c339e5 --- /dev/null +++ b/create-a-container/client/src/pages/auth/RegisterPage.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Alert, AlertDescription, AlertTitle, Button, Input, Spinner } from '@mieweb/ui'; +import { api, ApiError } from '@/lib/api'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; + +const schema = z + .object({ + uid: z.string().min(2, 'Username must be at least 2 characters').max(32), + givenName: z.string().min(1, 'First name is required'), + sn: z.string().min(1, 'Last name is required'), + mail: z.string().email('Valid email required'), + userPassword: z.string().min(8, 'At least 8 characters'), + confirmPassword: z.string(), + }) + .refine((d) => d.userPassword === d.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + +type FormData = z.infer; + +interface InviteData { + email: string; +} +interface RegisterResponse { + uid: string; + status: 'active' | 'pending'; + message: string; + twoFactor?: { enrollmentToken?: string; warning?: string }; +} + +export function RegisterPage() { + useDocumentTitle('Create account'); + const navigate = useNavigate(); + const { token } = useParams<{ token?: string }>(); + const [invite, setInvite] = useState(null); + const [inviteError, setInviteError] = useState(null); + const [inviteLoading, setInviteLoading] = useState(!!token); + const [submitError, setSubmitError] = useState(null); + + const form = useForm({ resolver: zodResolver(schema) }); + const { register, handleSubmit, formState, reset } = form; + + useEffect(() => { + if (!token) return; + let cancelled = false; + (async () => { + try { + const data = await api.get( + `/api/v1/auth/register/invite/${encodeURIComponent(token)}`, + ); + if (cancelled) return; + setInvite(data); + reset({ mail: data.email } as Partial); + } catch (err) { + if (cancelled) return; + setInviteError(err instanceof ApiError ? err.message : 'Invitation invalid'); + } finally { + if (!cancelled) setInviteLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [token, reset]); + + const onSubmit = handleSubmit(async (values) => { + setSubmitError(null); + try { + const { confirmPassword: _confirm, ...payload } = values; + void _confirm; + const res = await api.post('/api/v1/auth/register', { + ...payload, + inviteToken: token, + }); + navigate('/register/success', { + state: { + uid: res.uid, + status: res.status, + message: res.message, + enrollmentToken: res.twoFactor?.enrollmentToken, + warning: res.twoFactor?.warning, + }, + }); + } catch (err) { + setSubmitError(err instanceof ApiError ? err.message : 'Registration failed'); + } + }); + + if (inviteLoading) { + return ( +
+ +
+ ); + } + if (token && inviteError) { + return ( + + Invitation invalid + {inviteError} + + ); + } + + return ( +
+
+

+ Create your account +

+ {invite && ( +

+ Invitation for {invite.email} +

+ )} +
+ + {submitError && ( + + Could not register + {submitError} + + )} + + +
+ + +
+ + + + + + +

+ Already have an account?{' '} + + Sign in + +

+
+ ); +} diff --git a/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx new file mode 100644 index 00000000..05ab2502 --- /dev/null +++ b/create-a-container/client/src/pages/auth/RegisterSuccessPage.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState } from 'react'; +import { Link, useLocation } from 'react-router'; +import { Alert, AlertDescription, AlertTitle, Spinner } from '@mieweb/ui'; +import { api, ApiError } from '@/lib/api'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; + +interface RegisterState { + uid?: string; + status?: 'active' | 'pending'; + message?: string; + enrollmentToken?: string; + warning?: string; +} + +export function RegisterSuccessPage() { + useDocumentTitle('Account created'); + const location = useLocation(); + const state = (location.state as RegisterState | null) || {}; + const [qr, setQr] = useState<{ qrCodeDataUri: string; inviteUrl: string } | null>(null); + const [qrError, setQrError] = useState(null); + const [qrLoading, setQrLoading] = useState(false); + + useEffect(() => { + if (!state.enrollmentToken) return; + let cancelled = false; + setQrLoading(true); + (async () => { + try { + const data = await api.get<{ qrCodeDataUri: string; inviteUrl: string }>( + `/api/v1/auth/register/2fa-qr/${encodeURIComponent(state.enrollmentToken!)}`, + ); + if (!cancelled) setQr(data); + } catch (err) { + if (!cancelled) setQrError(err instanceof ApiError ? err.message : 'QR code unavailable'); + } finally { + if (!cancelled) setQrLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [state.enrollmentToken]); + + return ( +
+
+

+ {state.status === 'active' ? 'Welcome aboard' : 'Almost there'} +

+

+ {state.message || + (state.status === 'active' + ? 'Your account is ready. You can sign in.' + : 'Your account is awaiting administrator approval.')} +

+
+ + {state.warning && ( + + Notice + {state.warning} + + )} + + {state.enrollmentToken && ( +
+

+ Enroll your second factor +

+

+ Scan this QR code with the push-notification app to register your device for 2FA. +

+ {qrLoading && ( +
+ +
+ )} + {qrError && ( + + {qrError} + + )} + {qr && ( + + )} +
+ )} + + + Continue to sign in + +
+ ); +} diff --git a/create-a-container/client/src/pages/auth/ResetPasswordPage.tsx b/create-a-container/client/src/pages/auth/ResetPasswordPage.tsx new file mode 100644 index 00000000..1687b9c2 --- /dev/null +++ b/create-a-container/client/src/pages/auth/ResetPasswordPage.tsx @@ -0,0 +1,135 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Alert, AlertDescription, AlertTitle, Button, Input, Spinner } from '@mieweb/ui'; +import { api, ApiError } from '@/lib/api'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; + +const schema = z + .object({ + password: z.string().min(8, 'At least 8 characters'), + confirmPassword: z.string(), + }) + .refine((d) => d.password === d.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); +type FormData = z.infer; + +export function ResetPasswordPage() { + useDocumentTitle('Set new password'); + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const [username, setUsername] = useState(null); + const [tokenError, setTokenError] = useState(null); + const [loading, setLoading] = useState(true); + const [submitError, setSubmitError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }); + + useEffect(() => { + if (!token) { + setTokenError('Missing reset token'); + setLoading(false); + return; + } + let cancelled = false; + (async () => { + try { + const data = await api.get<{ username: string }>( + `/api/v1/auth/password-reset/${encodeURIComponent(token)}`, + ); + if (!cancelled) setUsername(data.username); + } catch (err) { + if (!cancelled) + setTokenError(err instanceof ApiError ? err.message : 'Invalid or expired reset link'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [token]); + + const onSubmit = handleSubmit(async (values) => { + setSubmitError(null); + try { + await api.post(`/api/v1/auth/password-reset/${encodeURIComponent(token!)}`, values); + navigate('/login', { replace: true }); + } catch (err) { + setSubmitError(err instanceof ApiError ? err.message : 'Could not reset password'); + } + }); + + if (loading) { + return ( +
+ +
+ ); + } + if (tokenError) { + return ( +
+ + Reset link invalid + {tokenError} + + + Request a new link + +
+ ); + } + + return ( +
+
+

+ Set a new password +

+ {username && ( +

+ For account {username} +

+ )} +
+ {submitError && ( + + {submitError} + + )} + + + +
+ ); +} diff --git a/create-a-container/client/src/pages/auth/ResetPasswordRequestPage.tsx b/create-a-container/client/src/pages/auth/ResetPasswordRequestPage.tsx new file mode 100644 index 00000000..22f5f4fe --- /dev/null +++ b/create-a-container/client/src/pages/auth/ResetPasswordRequestPage.tsx @@ -0,0 +1,89 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Alert, AlertDescription, AlertTitle, Button, Input } from '@mieweb/ui'; +import { api, ApiError } from '@/lib/api'; +import { useDocumentTitle } from '@/lib/useDocumentTitle'; + +const schema = z.object({ + usernameOrEmail: z.string().min(1, 'Required'), +}); +type FormData = z.infer; + +export function ResetPasswordRequestPage() { + useDocumentTitle('Reset password'); + const [submitted, setSubmitted] = useState(false); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ resolver: zodResolver(schema) }); + + const onSubmit = handleSubmit(async (values) => { + setError(null); + try { + await api.post('/api/v1/auth/password-reset/request', values); + setSubmitted(true); + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Request failed'); + } + }); + + if (submitted) { + return ( +
+ + Check your inbox + + If the account exists, we sent password reset instructions. + + + + Back to sign in + +
+ ); + } + + return ( +
+
+

+ Reset password +

+

+ Enter your username or email and we’ll send a reset link. +

+
+ {error && ( + + {error} + + )} + + + + Back to sign in + +
+ ); +} diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx new file mode 100644 index 00000000..9b01dafd --- /dev/null +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -0,0 +1,519 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Alert, + AlertDescription, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, + Input, + Select, + Spinner, + Switch, + useToast, +} from '@mieweb/ui'; +import { Container, Plus, Search, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageHeader } from '@/components/FormPageHeader'; +import type { ContainerCreateResult, ContainerMetadata } from '@/lib/types'; + +const SERVICE_TYPES = [ + { value: 'http', label: 'HTTP' }, + { value: 'https', label: 'HTTPS (backend TLS)' }, + { value: 'tcp', label: 'TCP' }, + { value: 'udp', label: 'UDP' }, + { value: 'srv', label: 'DNS (SRV record)' }, +]; + +const serviceSchema = z.object({ + id: z.number().optional(), + type: z.enum(['http', 'https', 'tcp', 'udp', 'srv']), + internalPort: z.string(), + externalHostname: z.string().optional(), + externalDomainId: z.string().optional(), + dnsName: z.string().optional(), + authRequired: z.boolean().optional(), + deleted: z.boolean().optional(), +}); + +const envVarSchema = z.object({ key: z.string(), value: z.string() }); + +const schema = z.object({ + hostname: z.string().min(1, 'Required'), + template: z.string().optional(), + customTemplate: z.string().optional(), + entrypoint: z.string().optional(), + nvidiaRequested: z.boolean().optional(), + restart: z.boolean().optional(), + services: z.array(serviceSchema), + environmentVars: z.array(envVarSchema), +}); +type FormData = z.infer; + +const COMMON_TEMPLATES = [ + 'ubuntu-22.04-standard', + 'ubuntu-24.04-standard', + 'debian-12-standard', + 'docker:nginx:latest', + 'docker:postgres:16', +]; + +const sectionCardClass = 'overflow-hidden shadow-sm'; +const sectionHeaderClass = 'flex flex-row items-center justify-between gap-3 border-b border-border bg-muted/30 px-6 py-4'; +const sectionContentClass = 'grid gap-4 px-6 py-6'; + +export function ContainerFormPage() { + const { siteId, id } = useParams<{ siteId: string; id?: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: bootstrap, isLoading: bootstrapLoading } = useQuery({ + queryKey: keys.containerBootstrap(siteId!), + queryFn: () => queries.containerBootstrap(siteId!), + enabled: !!siteId, + }); + const { data: container, isLoading: containerLoading } = useQuery({ + queryKey: keys.container(siteId!, id ?? 'new'), + queryFn: () => queries.getContainer(siteId!, id!), + enabled: isEdit, + }); + + const { register, handleSubmit, control, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + services: [], + environmentVars: [], + nvidiaRequested: false, + restart: false, + }, + }); + const services = useFieldArray({ control, name: 'services' }); + const envVars = useFieldArray({ control, name: 'environmentVars' }); + const template = watch('template'); + const nvidiaRequested = watch('nvidiaRequested'); + const restart = watch('restart'); + + useEffect(() => { + if (container && isEdit) { + reset({ + hostname: container.hostname, + template: container.template || '', + entrypoint: container.entrypoint || '', + nvidiaRequested: container.nvidiaRequested, + restart: false, + services: container.services.map((s) => ({ + id: s.id, + type: + s.type === 'dns' + ? 'srv' + : s.type === 'http' + ? s.httpService?.backendProtocol === 'https' + ? 'https' + : 'http' + : (s.transportService?.protocol ?? 'tcp'), + internalPort: String(s.internalPort), + externalHostname: s.httpService?.externalHostname || '', + externalDomainId: s.httpService ? String(s.httpService.externalDomainId) : '', + dnsName: s.dnsService?.dnsName || '', + authRequired: !!s.httpService?.authRequired, + deleted: false, + })), + environmentVars: Object.entries(container.environmentVars || {}).map(([key, value]) => ({ + key, + value, + })), + }); + } + }, [container, isEdit, reset]); + + const [metadataMsg, setMetadataMsg] = useState(null); + const metadataMutation = useMutation({ + mutationFn: (image: string) => queries.containerMetadata(siteId!, image), + onSuccess: (meta: ContainerMetadata) => { + const ports = (meta.exposedPorts || []).filter((p) => /^\d+(\/\w+)?$/.test(p)); + let added = 0; + ports.forEach((p) => { + const [portStr, proto] = p.split('/'); + const port = parseInt(portStr, 10); + const t = !proto || proto === 'tcp' ? 'http' : 'tcp'; + services.append({ + type: t as FormData['services'][number]['type'], + internalPort: String(port), + externalHostname: '', + externalDomainId: '', + dnsName: '', + authRequired: false, + deleted: false, + }); + added += 1; + }); + if (meta.entrypoint) { + const ep = Array.isArray(meta.entrypoint) ? meta.entrypoint.join(' ') : meta.entrypoint; + setValue('entrypoint', ep); + } + if (Array.isArray(meta.env)) { + meta.env.forEach((e) => { + const eq = e.indexOf('='); + if (eq > 0) envVars.append({ key: e.slice(0, eq), value: e.slice(eq + 1) }); + }); + } + setMetadataMsg(`Loaded metadata: ${added} port(s) discovered.`); + }, + onError: (err: ApiError) => setMetadataMsg(err.message), + }); + + const mutation = useMutation({ + mutationFn: (values: FormData) => { + const servicesObj: Record = {}; + values.services.forEach((s, idx) => { + if (s.deleted && s.id) { + servicesObj[`s${idx}`] = { id: s.id, deleted: true }; + return; + } + if (s.deleted) return; + servicesObj[`s${idx}`] = { + id: s.id, + type: s.type, + internalPort: s.internalPort ? parseInt(s.internalPort, 10) : undefined, + externalHostname: s.externalHostname, + externalDomainId: s.externalDomainId ? parseInt(s.externalDomainId, 10) : undefined, + dnsName: s.dnsName, + authRequired: s.authRequired, + }; + }); + const payload = { + hostname: values.hostname, + template: values.template === 'custom' ? values.customTemplate : values.template, + customTemplate: values.customTemplate, + entrypoint: values.entrypoint, + nvidiaRequested: values.nvidiaRequested, + services: servicesObj, + environmentVars: values.environmentVars.filter((e) => e.key.trim()), + restart: values.restart, + }; + type UpdateResult = { + containerId: number; + jobId: number | null; + message: string; + dnsWarnings: string[]; + }; + type SaveResult = UpdateResult | ContainerCreateResult; + return ( + isEdit + ? api.put(`/api/v1/sites/${siteId}/containers/${id}`, payload) + : api.post(`/api/v1/sites/${siteId}/containers`, payload) + ) as Promise; + }, + onSuccess: (result) => { + const dnsWarnings = (result as { dnsWarnings?: string[] }).dnsWarnings; + if (dnsWarnings && dnsWarnings.length > 0) { + toast.warning(`Saved with DNS warnings: ${dnsWarnings.join('; ')}`); + } else { + toast.success(isEdit ? 'Container updated' : 'Container queued for creation'); + } + qc.invalidateQueries({ queryKey: keys.containers(siteId!) }); + const jobId = (result as { jobId?: number | null }).jobId; + if (jobId) { + navigate(`/jobs/${jobId}`); + } else { + navigate(`/sites/${siteId}/containers`); + } + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if ((isEdit && containerLoading) || bootstrapLoading) { + return ( +
+ +
+ ); + } + + const domainOptions = [ + { value: '', label: '— None —' }, + ...(bootstrap?.externalDomains.map((d) => ({ value: String(d.id), label: d.name })) || []), + ]; + const templateOptions = [ + { value: '', label: 'Select a template' }, + ...COMMON_TEMPLATES.map((t) => ({ value: t, label: t })), + { value: 'custom', label: 'Custom…' }, + ]; + + return ( +
mutation.mutate(v))} noValidate> +
+ } + title={isEdit ? `Edit container: ${container?.hostname ?? ''}` : 'New container'} + subtitle={ + isEdit + ? 'Update the container, its services, and environment variables.' + : 'Configure a new container, expose services, and set environment variables.' + } + backTo={{ label: 'Back to containers', to: `/sites/${siteId}/containers` }} + /> + + + + Basics + + + + {!isEdit && ( + <> + + )} +
+
+ +
+ +
+ {metadataMsg && ( +

{metadataMsg}

+ )} + + )} + + {bootstrap?.nvidiaAvailable && ( + setValue('nvidiaRequested', c)} + /> + )} + {isEdit && ( + setValue('restart', c)} + /> + )} +
+
+ + + + Services + + + + {services.fields.length === 0 && ( +

No services defined.

+ )} + {services.fields.map((f, idx) => { + const svc = watch(`services.${idx}`); + if (svc.deleted) return null; + return ( +
+
+ + +
+ {(svc.type === 'http' || svc.type === 'https') && ( +
+ + + )} +
+ ); + })} + + + + + + Environment variables + + + + {envVars.fields.length === 0 && ( +

No environment variables.

+ )} + {envVars.fields.map((f, idx) => ( +
+ + + +
+ ))} +
+ + + + +
+ + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} +
+ + ); +} diff --git a/create-a-container/client/src/pages/containers/ContainersListPage.tsx b/create-a-container/client/src/pages/containers/ContainersListPage.tsx new file mode 100644 index 00000000..ae6c8501 --- /dev/null +++ b/create-a-container/client/src/pages/containers/ContainersListPage.tsx @@ -0,0 +1,188 @@ +import { Link, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { Container as ContainerIcon, ExternalLink, Pencil, Plus, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { Container } from '@/lib/types'; + +function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' | 'secondary' { + switch (s) { + case 'running': + return 'success'; + case 'pending': + case 'restarting': + return 'warning'; + case 'failed': + case 'error': + return 'danger'; + case 'stopped': + return 'secondary'; + default: + return 'default'; + } +} + +export function ContainersListPage() { + const { siteId } = useParams<{ siteId: string }>(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: site } = useQuery({ + queryKey: keys.site(siteId!), + queryFn: () => queries.getSite(siteId!), + enabled: !!siteId, + }); + const { data, isLoading, error } = useQuery({ + queryKey: keys.containers(siteId!), + queryFn: () => queries.listContainers(siteId!), + enabled: !!siteId, + }); + + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/containers/${id}`), + onSuccess: () => { + toast.success('Container deleted'); + qc.invalidateQueries({ queryKey: keys.containers(siteId!) }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ +
+ + + + + + +
+ } + /> + + {error && ( + + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + {data && data.length === 0 && ( + + No containers + Create your first container with the button above. + + )} + + {data && data.length > 0 && ( + + + + Hostname + Status + Node + Template + HTTP + SSH + Actions + + + + {data.map((c: Container) => ( + + {c.hostname} + + {c.status} + + {c.nodeName || '—'} + + {c.template || '—'} + + + {c.httpEntries.length === 0 ? ( + '—' + ) : ( +
+ {c.httpEntries.slice(0, 2).map((h) => + h.externalUrl ? ( + + + {h.externalUrl.replace(/^https?:\/\//, '')} + + ) : ( + + :{h.port} + + ), + )} +
+ )} +
+ + {c.sshHost && c.sshPort ? `${c.sshHost}:${c.sshPort}` : '—'} + + + {c.creationJobId && ( + + + + )} + + + + + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx new file mode 100644 index 00000000..df44ae3e --- /dev/null +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainFormPage.tsx @@ -0,0 +1,181 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Alert, + AlertDescription, + Button, + Input, + Select, + Spinner, + useToast, +} from '@mieweb/ui'; +import { Globe } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; +import type { ExternalDomain } from '@/lib/types'; + +const schema = z.object({ + name: z.string().min(1, 'Required'), + siteId: z.string().optional(), + acmeEmail: z.string().email('Must be a valid email').or(z.literal('')).optional(), + acmeDirectoryUrl: z.string().url('Must be a valid URL').or(z.literal('')).optional(), + cloudflareApiEmail: z.string().email('Must be a valid email').or(z.literal('')).optional(), + cloudflareApiKey: z.string().optional(), + authServer: z.string().optional(), +}); +type FormData = z.infer; + +export function ExternalDomainFormPage() { + const { id } = useParams<{ id?: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: domain, isLoading } = useQuery({ + queryKey: keys.externalDomain(id ?? 'new'), + queryFn: () => queries.getExternalDomain(id!), + enabled: isEdit, + }); + const { data: sites } = useQuery({ queryKey: keys.sites(), queryFn: queries.listSites }); + + const { register, handleSubmit, reset, setValue, watch, formState } = useForm({ + resolver: zodResolver(schema), + }); + const siteIdValue = watch('siteId'); + + useEffect(() => { + if (domain) { + reset({ + name: domain.name, + siteId: domain.siteId ? String(domain.siteId) : '', + acmeEmail: domain.acmeEmail || '', + acmeDirectoryUrl: domain.acmeDirectoryUrl || '', + cloudflareApiEmail: domain.cloudflareApiEmail || '', + cloudflareApiKey: '', + authServer: domain.authServer || '', + }); + } + }, [domain, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormData) => { + const payload = { + ...values, + siteId: values.siteId ? parseInt(values.siteId, 10) : null, + }; + return isEdit + ? api.put(`/api/v1/external-domains/${id}`, payload) + : api.post('/api/v1/external-domains', payload); + }, + onSuccess: () => { + toast.success(isEdit ? 'External domain updated' : 'External domain created'); + qc.invalidateQueries({ queryKey: keys.externalDomains() }); + navigate('/external-domains'); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (isEdit && isLoading) { + return ( +
+ +
+ ); + } + + return ( +
mutation.mutate(v))} noValidate> + } + title={isEdit ? 'Edit external domain' : 'New external domain'} + subtitle={ + isEdit + ? 'Update ACME, DNS, and authentication settings.' + : 'Configure a public domain with ACME certificate issuance and optional Cloudflare DNS.' + } + backTo={{ label: 'Back to external domains', to: '/external-domains' }} + actions={ + <> + + + + } + > + + + + + + + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} + +
+ ); +} diff --git a/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx new file mode 100644 index 00000000..36b58321 --- /dev/null +++ b/create-a-container/client/src/pages/external-domains/ExternalDomainsListPage.tsx @@ -0,0 +1,112 @@ +import { Link } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Badge, + Button, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { Globe, Pencil, Plus, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { ExternalDomain } from '@/lib/types'; + +export function ExternalDomainsListPage() { + const { data, isLoading, error } = useQuery({ + queryKey: keys.externalDomains(), + queryFn: queries.listExternalDomains, + }); + const qc = useQueryClient(); + const toast = useToast(); + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/external-domains/${id}`), + onSuccess: () => { + toast.success('External domain deleted'); + qc.invalidateQueries({ queryKey: keys.externalDomains() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ + + + + } + /> + {error && ( + + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + {data && ( + + + + Domain + Site + Cloudflare + Auth server + Actions + + + + {data.map((d: ExternalDomain) => ( + + {d.name} + {d.site?.name || '—'} + + {d.hasCloudflareApiKey ? ( + Configured + ) : ( + Not configured + )} + + {d.authServer || '—'} + + + + + + + + ))} + +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/groups/GroupFormPage.tsx b/create-a-container/client/src/pages/groups/GroupFormPage.tsx new file mode 100644 index 00000000..d3172fa8 --- /dev/null +++ b/create-a-container/client/src/pages/groups/GroupFormPage.tsx @@ -0,0 +1,131 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Alert, + AlertDescription, + Button, + Checkbox, + Input, + Spinner, + useToast, +} from '@mieweb/ui'; +import { UsersRound } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; +import type { Group } from '@/lib/types'; + +const schema = z.object({ + gidNumber: z.string().min(1, 'Required').regex(/^\d+$/, 'Must be a positive integer'), + cn: z.string().min(1, 'Required'), + isAdmin: z.boolean().optional(), +}); +type FormData = z.infer; + +export function GroupFormPage() { + const { id } = useParams<{ id?: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: group, isLoading } = useQuery({ + queryKey: keys.group(id ?? 'new'), + queryFn: () => queries.getGroup(id!), + enabled: isEdit, + }); + + const { register, handleSubmit, reset, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { isAdmin: false }, + }); + + useEffect(() => { + if (group) { + reset({ gidNumber: String(group.gidNumber), cn: group.cn, isAdmin: group.isAdmin }); + } + }, [group, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormData) => { + const payload = { ...values, gidNumber: parseInt(values.gidNumber, 10) }; + return isEdit + ? api.put(`/api/v1/groups/${id}`, payload) + : api.post('/api/v1/groups', payload); + }, + onSuccess: () => { + toast.success(isEdit ? 'Group updated' : 'Group created'); + qc.invalidateQueries({ queryKey: keys.groups() }); + navigate('/groups'); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (isEdit && isLoading) { + return ( +
+ +
+ ); + } + + return ( +
mutation.mutate(v))} noValidate> + } + title={isEdit ? 'Edit group' : 'New group'} + subtitle={ + isEdit + ? 'Update group details and admin status.' + : 'Create a POSIX group for users and access control.' + } + backTo={{ label: 'Back to groups', to: '/groups' }} + maxWidth="xl" + actions={ + <> + + + + } + > + + + + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} + +
+ ); +} diff --git a/create-a-container/client/src/pages/groups/GroupsListPage.tsx b/create-a-container/client/src/pages/groups/GroupsListPage.tsx new file mode 100644 index 00000000..18e82a53 --- /dev/null +++ b/create-a-container/client/src/pages/groups/GroupsListPage.tsx @@ -0,0 +1,92 @@ +import { Link } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Badge, + Button, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { Pencil, Plus, Trash2, UsersRound } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { Group } from '@/lib/types'; + +export function GroupsListPage() { + const { data, isLoading, error } = useQuery({ queryKey: keys.groups(), queryFn: queries.listGroups }); + const qc = useQueryClient(); + const toast = useToast(); + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/groups/${id}`), + onSuccess: () => { + toast.success('Group deleted'); + qc.invalidateQueries({ queryKey: keys.groups() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ + + + + } + /> + {error && {(error as ApiError).message}} + {isLoading &&
} + {data && ( + + + + GID + Name + Admin + Users + Actions + + + + {data.map((g: Group) => ( + + {g.gidNumber} + {g.cn} + {g.isAdmin ? Admin : No} + {g.userCount ?? 0} + + + + + + + + ))} + +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/jobs/JobDetailPage.tsx b/create-a-container/client/src/pages/jobs/JobDetailPage.tsx new file mode 100644 index 00000000..d5c4035b --- /dev/null +++ b/create-a-container/client/src/pages/jobs/JobDetailPage.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from 'react'; +import { Link, useParams } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Badge, + Button, + PageHeader, + Spinner, +} from '@mieweb/ui'; +import { ArrowLeft, Terminal } from 'lucide-react'; +import { ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { JobStatusRow } from '@/lib/types'; + +interface SseLogEvent { + id: number; + output: string; + timestamp: string; +} + +function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' | 'secondary' { + switch (s) { + case 'running': + return 'warning'; + case 'completed': + return 'success'; + case 'failed': + return 'danger'; + case 'cancelled': + return 'secondary'; + default: + return 'default'; + } +} + +export function JobDetailPage() { + const { id } = useParams<{ id: string }>(); + const { data: job, isLoading, error, refetch } = useQuery({ + queryKey: keys.job(id!), + queryFn: () => queries.getJob(id!), + enabled: !!id, + }); + + const [logs, setLogs] = useState([]); + const [liveStatus, setLiveStatus] = useState(null); + const containerRef = useRef(null); + + // Initial backfill of statuses. + useEffect(() => { + if (!id) return; + queries.getJobStatuses(id).then(setLogs).catch(() => undefined); + }, [id]); + + // Live SSE stream — only while job is pending/running. + useEffect(() => { + if (!id || !job) return; + if (!['pending', 'running'].includes(job.status)) return; + + const lastId = logs.length > 0 ? logs[logs.length - 1].id : 0; + const source = new EventSource(`/api/v1/jobs/${id}/stream?lastId=${lastId}`); + + source.addEventListener('log', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) as SseLogEvent; + setLogs((prev) => + prev.some((r) => r.id === data.id) + ? prev + : [...prev, { id: data.id, jobId: Number(id), output: data.output, createdAt: data.timestamp }], + ); + } catch { + /* ignore malformed payload */ + } + }); + source.addEventListener('status', (ev: MessageEvent) => { + try { + const data = JSON.parse(ev.data) as { status: string }; + setLiveStatus(data.status); + refetch(); + } catch { + /* ignore */ + } + source.close(); + }); + source.onerror = () => source.close(); + + return () => source.close(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, job?.status]); + + // Auto-scroll on new log lines. + useEffect(() => { + const el = containerRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [logs.length]); + + if (isLoading) return
; + if (error || !job) { + return ( + + {(error as ApiError | null)?.message || 'Job not found'} + + ); + } + + const effectiveStatus = liveStatus || job.status; + + return ( +
+ } + actions={ + + + + } + /> + +
+ {effectiveStatus} + + Started {new Date(job.createdAt).toLocaleString()} · by {job.createdBy} + +
+ +
+ {logs.length === 0 ? ( + No log output yet… + ) : ( + logs.map((row) => ( +
+              {row.output}
+            
+ )) + )} +
+
+ ); +} diff --git a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx new file mode 100644 index 00000000..f3a36799 --- /dev/null +++ b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx @@ -0,0 +1,192 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Alert, + AlertDescription, + Button, + Input, + Spinner, + Switch, + useToast, +} from '@mieweb/ui'; +import { Server } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; +import type { Node } from '@/lib/types'; + +const schema = z.object({ + name: z.string().min(1, 'Required'), + ipv4Address: z.string().optional(), + apiUrl: z.string().url('Must be a valid URL').or(z.literal('')).optional(), + tokenId: z.string().optional(), + secret: z.string().optional(), + tlsVerify: z.boolean().optional(), + imageStorage: z.string().min(1, 'Required'), + volumeStorage: z.string().min(1, 'Required'), + networkBridge: z.string().min(1, 'Required'), + nvidiaAvailable: z.boolean().optional(), +}); +type FormData = z.infer; + +export function NodeFormPage() { + const { siteId, id } = useParams<{ siteId: string; id?: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: node, isLoading } = useQuery({ + queryKey: keys.node(siteId!, id ?? 'new'), + queryFn: () => queries.getNode(siteId!, id!), + enabled: isEdit, + }); + + const { register, handleSubmit, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + tlsVerify: true, + nvidiaAvailable: false, + imageStorage: 'local', + volumeStorage: 'local-lvm', + networkBridge: 'vmbr0', + }, + }); + const tlsVerify = watch('tlsVerify'); + const nvidiaAvailable = watch('nvidiaAvailable'); + + useEffect(() => { + if (node) { + reset({ + name: node.name, + ipv4Address: node.ipv4Address || '', + apiUrl: node.apiUrl || '', + tokenId: node.tokenId || '', + secret: '', + tlsVerify: node.tlsVerify ?? true, + imageStorage: node.imageStorage, + volumeStorage: node.volumeStorage, + networkBridge: node.networkBridge, + nvidiaAvailable: node.nvidiaAvailable, + }); + } + }, [node, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormData) => { + const payload = { ...values }; + if (isEdit && !values.secret) delete payload.secret; + return isEdit + ? api.put(`/api/v1/sites/${siteId}/nodes/${id}`, payload) + : api.post(`/api/v1/sites/${siteId}/nodes`, payload); + }, + onSuccess: () => { + toast.success(isEdit ? 'Node updated' : 'Node created'); + qc.invalidateQueries({ queryKey: keys.nodes(siteId!) }); + navigate(`/sites/${siteId}/nodes`); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (isEdit && isLoading) { + return ( +
+ +
+ ); + } + + return ( +
mutation.mutate(v))} noValidate> + } + title={isEdit ? 'Edit node' : 'New node'} + subtitle={ + isEdit + ? 'Update Proxmox connection details and storage settings.' + : 'Register a Proxmox node with API credentials and default storage.' + } + backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }} + maxWidth="3xl" + actions={ + <> + + + + } + > + + + + + + setValue('tlsVerify', c)} + /> +
+ + + +
+ setValue('nvidiaAvailable', c)} + /> + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} +
+
+ ); +} diff --git a/create-a-container/client/src/pages/nodes/NodeImportPage.tsx b/create-a-container/client/src/pages/nodes/NodeImportPage.tsx new file mode 100644 index 00000000..a0a098c7 --- /dev/null +++ b/create-a-container/client/src/pages/nodes/NodeImportPage.tsx @@ -0,0 +1,110 @@ +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Alert, AlertDescription, Button, Input, Switch, useToast } from '@mieweb/ui'; +import { Download } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; + +const schema = z.object({ + apiUrl: z.string().url('Must be a valid URL'), + username: z.string().min(1, 'Required'), + password: z.string().min(1, 'Required'), + tlsVerify: z.boolean().optional(), +}); +type FormData = z.infer; + +export function NodeImportPage() { + const { siteId } = useParams<{ siteId: string }>(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { register, handleSubmit, watch, setValue, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { tlsVerify: true }, + }); + const tlsVerify = watch('tlsVerify'); + + const mutation = useMutation({ + mutationFn: (values: FormData) => + api.post<{ imported: number }>(`/api/v1/sites/${siteId}/nodes/import-proxmox`, values), + onSuccess: (result) => { + toast.success(`Imported ${result.imported} node(s)`); + qc.invalidateQueries({ queryKey: keys.nodes(siteId!) }); + navigate(`/sites/${siteId}/nodes`); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
mutation.mutate(v))} noValidate> + } + title="Import nodes from Proxmox" + subtitle="Bulk import from a Proxmox cluster" + description="Sign in to a Proxmox cluster and import every node along with its existing LXC containers into this site." + backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }} + maxWidth="xl" + actions={ + <> + + + + } + > + + + + setValue('tlsVerify', c)} + /> + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} + +
+ ); +} diff --git a/create-a-container/client/src/pages/nodes/NodesListPage.tsx b/create-a-container/client/src/pages/nodes/NodesListPage.tsx new file mode 100644 index 00000000..d2628c06 --- /dev/null +++ b/create-a-container/client/src/pages/nodes/NodesListPage.tsx @@ -0,0 +1,103 @@ +import { Link, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Badge, + Button, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { Download, Pencil, Plus, Server, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { Node } from '@/lib/types'; + +export function NodesListPage() { + const { siteId } = useParams<{ siteId: string }>(); + const qc = useQueryClient(); + const toast = useToast(); + const { data: site } = useQuery({ queryKey: keys.site(siteId!), queryFn: () => queries.getSite(siteId!), enabled: !!siteId }); + const { data, isLoading, error } = useQuery({ + queryKey: keys.nodes(siteId!), + queryFn: () => queries.listNodes(siteId!), + enabled: !!siteId, + }); + + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/sites/${siteId}/nodes/${id}`), + onSuccess: () => { + toast.success('Node deleted'); + qc.invalidateQueries({ queryKey: keys.nodes(siteId!) }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ +
+ + + + + + +
+ } + /> + {error && {(error as ApiError).message}} + {isLoading &&
} + {data && ( + + + + Name + IPv4 + API URL + NVIDIA + Credentials + Actions + + + + {data.map((n: Node) => ( + + {n.name} + {n.ipv4Address || '—'} + {n.apiUrl || '—'} + {n.nvidiaAvailable ? Available : No} + {n.hasSecret ? Set : Missing} + + + + + + + + ))} + +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/settings/SettingsPage.tsx b/create-a-container/client/src/pages/settings/SettingsPage.tsx new file mode 100644 index 00000000..a99b301b --- /dev/null +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -0,0 +1,140 @@ +import { useEffect } from 'react'; +import { useFieldArray, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + Button, + Input, + PageHeader, + Spinner, + Switch, + useToast, +} from '@mieweb/ui'; +import { Plus, Settings as SettingsIcon, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import type { AppSettings } from '@/lib/types'; + +const envVarSchema = z.object({ + key: z.string(), + value: z.string(), + description: z.string().optional(), +}); + +const schema = z.object({ + pushNotificationEnabled: z.boolean(), + pushNotificationUrl: z.string(), + pushNotificationApiKey: z.string(), + smtpUrl: z.string(), + smtpNoreplyAddress: z.string(), + defaultContainerEnvVars: z.array(envVarSchema), +}).refine( + (v) => !v.pushNotificationEnabled || v.pushNotificationUrl.trim() !== '', + { path: ['pushNotificationUrl'], message: 'URL is required when push notifications are enabled' }, +); +type FormData = z.infer; + +export function SettingsPage() { + const qc = useQueryClient(); + const toast = useToast(); + const { data, isLoading, error } = useQuery({ queryKey: keys.settings(), queryFn: queries.getSettings }); + + const { register, handleSubmit, reset, control, watch, setValue, formState } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + pushNotificationEnabled: false, + pushNotificationUrl: '', + pushNotificationApiKey: '', + smtpUrl: '', + smtpNoreplyAddress: '', + defaultContainerEnvVars: [], + }, + }); + const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); + const pushEnabled = watch('pushNotificationEnabled'); + + useEffect(() => { + if (data) reset(data); + }, [data, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormData) => api.put('/api/v1/settings', values), + onSuccess: () => { + toast.success('Settings saved'); + qc.invalidateQueries({ queryKey: keys.settings() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (isLoading) return
; + if (error) return {(error as ApiError).message}; + + return ( +
+ } bordered /> +
mutation.mutate(v))} className="grid max-w-3xl gap-8"> +
+

Push notifications

+ setValue('pushNotificationEnabled', c)} + /> + + +
+ +
+

SMTP

+ + +
+ +
+
+

Default container environment variables

+ +
+ {fields.length === 0 &&

No defaults defined.

} + {fields.map((f, idx) => ( +
+ + + + +
+ ))} +
+ + {mutation.error && {(mutation.error as ApiError).message}} + +
+ +
+
+
+ ); +} diff --git a/create-a-container/client/src/pages/sites/SiteFormPage.tsx b/create-a-container/client/src/pages/sites/SiteFormPage.tsx new file mode 100644 index 00000000..e475cb49 --- /dev/null +++ b/create-a-container/client/src/pages/sites/SiteFormPage.tsx @@ -0,0 +1,148 @@ +import { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Alert, AlertDescription, Button, Input, Spinner, useToast } from '@mieweb/ui'; +import { Building2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { FormPageLayout } from '@/components/FormPageLayout'; +import type { Site } from '@/lib/types'; + +const schema = z.object({ + name: z.string().min(1, 'Required'), + internalDomain: z.string().min(1, 'Required'), + dhcpRange: z.string().optional().nullable(), + subnetMask: z.string().optional().nullable(), + gateway: z.string().optional().nullable(), + dnsForwarders: z.string().optional().nullable(), + externalIp: z.string().optional().nullable(), +}); +type FormData = z.infer; + +export function SiteFormPage() { + const { id } = useParams<{ id?: string }>(); + const isEdit = !!id; + const navigate = useNavigate(); + const qc = useQueryClient(); + const toast = useToast(); + + const { data: site, isLoading } = useQuery({ + queryKey: keys.site(id ?? 'new'), + queryFn: () => queries.getSite(id!), + enabled: isEdit, + }); + + const form = useForm({ resolver: zodResolver(schema) }); + const { register, handleSubmit, reset, formState } = form; + + useEffect(() => { + if (site) reset(site as FormData); + }, [site, reset]); + + const mutation = useMutation({ + mutationFn: (values: FormData) => + isEdit + ? api.put(`/api/v1/sites/${id}`, values) + : api.post('/api/v1/sites', values), + onSuccess: (saved) => { + toast.success(isEdit ? 'Site updated' : 'Site created'); + qc.invalidateQueries({ queryKey: keys.sites() }); + navigate(`/sites/${saved.id}/containers`); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + if (isEdit && isLoading) { + return ( +
+ +
+ ); + } + + return ( +
mutation.mutate(v))} noValidate> + } + title={isEdit ? 'Edit site' : 'New site'} + subtitle={ + isEdit + ? 'Update networking and DNS settings for this site.' + : 'Define a new site with its internal network, DHCP, and DNS.' + } + backTo={{ label: 'Back to sites', to: '/sites' }} + actions={ + <> + + + + } + > + + +
+ + +
+
+ + +
+ + {mutation.error && ( + + {(mutation.error as ApiError).message} + + )} +
+
+ ); +} diff --git a/create-a-container/client/src/pages/sites/SitesListPage.tsx b/create-a-container/client/src/pages/sites/SitesListPage.tsx new file mode 100644 index 00000000..20c06e1d --- /dev/null +++ b/create-a-container/client/src/pages/sites/SitesListPage.tsx @@ -0,0 +1,134 @@ +import { Link } from 'react-router'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + PageHeader, + Spinner, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + useToast, +} from '@mieweb/ui'; +import { Building2, Pencil, Plus, Trash2 } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; +import { keys, queries } from '@/lib/queries'; +import { useSession } from '@/lib/auth'; +import type { Site } from '@/lib/types'; + +export function SitesListPage() { + const { data: session } = useSession(); + const { data, isLoading, error } = useQuery({ + queryKey: keys.sites(), + queryFn: queries.listSites, + }); + const qc = useQueryClient(); + const toast = useToast(); + const del = useMutation({ + mutationFn: (id: number) => api.delete(`/api/v1/sites/${id}`), + onSuccess: () => { + toast.success('Site deleted'); + qc.invalidateQueries({ queryKey: keys.sites() }); + }, + onError: (err: ApiError) => toast.error(err.message), + }); + + return ( +
+ } + actions={ + session?.isAdmin && ( + + + + ) + } + /> + + {error && ( + + Failed to load sites + {(error as ApiError).message} + + )} + {isLoading && ( +
+ +
+ )} + {data && data.length === 0 && ( + + No sites yet + Create a site to begin managing nodes and containers. + + )} + {data && data.length > 0 && ( + + + + Name + Internal domain + Gateway + External IP + Nodes + Actions + + + + {data.map((s: Site) => ( + + + + {s.name} + + + {s.internalDomain} + {s.gateway || '—'} + {s.externalIp || '—'} + + {s.nodeCount ?? 0} + + + {session?.isAdmin && ( + <> + + + + + + )} + + + ))} + +
+ )} +
+ ); +} diff --git a/create-a-container/client/src/pages/users/EmailAllModal.tsx b/create-a-container/client/src/pages/users/EmailAllModal.tsx new file mode 100644 index 00000000..ef1cd7a6 --- /dev/null +++ b/create-a-container/client/src/pages/users/EmailAllModal.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { + Button, + Input, + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, + Textarea, + useToast, +} from '@mieweb/ui'; +import { Send } from 'lucide-react'; +import { api, ApiError } from '@/lib/api'; + +interface EmailAllModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + recipientCount: number; +} + +interface EmailAllResponse { + sent: number; + failed: number; + recipients: number; +} + +export function EmailAllModal({ open, onOpenChange, recipientCount }: EmailAllModalProps) { + const toast = useToast(); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + + const mutation = useMutation({ + mutationFn: (body) => api.post('/api/v1/users/email-all', body), + onSuccess: (res) => { + toast.success( + res.failed > 0 + ? `Sent ${res.sent} of ${res.recipients} (${res.failed} failed)` + : `Email sent to ${res.sent} user${res.sent === 1 ? '' : 's'}`, + ); + setSubject(''); + setMessage(''); + onOpenChange(false); + }, + onError: (err) => toast.error(err.message), + }); + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!subject.trim() || !message.trim()) return; + mutation.mutate({ subject: subject.trim(), message }); + }; + + return ( + + + Email all users + + +
+ +

+ This message will be sent to{' '} + {recipientCount} user{recipientCount === 1 ? '' : 's'} with an email + address on file. +

+
+ + setSubject(e.target.value)} + placeholder="Announcement subject" + required + autoFocus + /> +
+
+ + -
-
- - - - - - -<%- include('../layouts/footer') %> diff --git a/create-a-container/views/users/invite.ejs b/create-a-container/views/users/invite.ejs deleted file mode 100644 index 6c9d002f..00000000 --- a/create-a-container/views/users/invite.ejs +++ /dev/null @@ -1,46 +0,0 @@ -<%- include('../layouts/header', { - title: 'Invite User - MIE', - breadcrumbs: [ - { label: 'Users', url: '/users' }, - { label: 'Invite', url: '#' } - ], - colWidth: 'col-lg-6', - req -}) %> - -
-
-

Invite User

-

- Send an invitation email to a new user. They will receive a link to register their account, - and their account will be automatically activated upon registration. -

- -
-
- - -
- The invitation link will be sent to this email address and will expire in 24 hours. -
-
- -
- - Cancel -
-
-
-
- -<%- include('../layouts/footer') %>