diff --git a/.gitignore b/.gitignore index 5ef6a52..0feaeac 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma + +# uploaded transfer payloads (runtime user data) +/uploads diff --git a/components.json b/components.json new file mode 100644 index 0000000..aa5f2a2 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..3667bdf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,9 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: ["src/generated/**", ".next/**"], + }, ]; export default eslintConfig; diff --git a/next.config.ts b/next.config.ts index e9ffa30..f917d61 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + eslint: { + ignoreDuringBuilds: true, + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 71c68ec..4ac7c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,19 +8,50 @@ "name": "transfer-tribe", "version": "0.1.0", "dependencies": { + "@auth/prisma-adapter": "^2.10.0", + "@prisma/client": "^6.10.1", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.7", + "bcrypt": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "fflate": "^0.8.2", + "formidable": "^3.5.4", + "lucide-react": "^0.523.0", "next": "15.3.4", + "next-auth": "^4.24.11", + "next-themes": "^0.4.6", + "node-mailjet": "^6.0.8", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", + "react-icons": "^5.5.0", + "sonner": "^2.0.5", + "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", - "@types/node": "^20", + "@types/bcrypt": "^6.0.0", + "@types/formidable": "^3.4.6", + "@types/node": "^20.19.26", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "15.3.4", + "prisma": "^6.10.1", "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", "typescript": "^5" } }, @@ -51,6 +82,164 @@ "node": ">=6.0.0" } }, + "node_modules/@auth/core": { + "version": "0.34.2", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.2.tgz", + "integrity": "sha512-KywHKRgLiF3l7PLyL73fjLSIBe1YNcA6sMeew4yMP6cfCWGXZrkkXd32AjRi1hlJ9nvovUBGZHvbn+LijO6ZeQ==", + "license": "ISC", + "optional": true, + "peer": true, + "dependencies": { + "@panva/hkdf": "^1.1.1", + "@types/cookie": "0.6.0", + "cookie": "0.6.0", + "jose": "^5.1.3", + "oauth4webapi": "^2.10.4", + "preact": "10.11.3", + "preact-render-to-string": "5.2.3" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/core/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@auth/core/node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/core/node_modules/preact": { + "version": "10.11.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", + "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/@auth/core/node_modules/preact-render-to-string": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", + "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.10.0.tgz", + "integrity": "sha512-EliOQoTjGK87jWWqnJvlQjbR4PjQZQqtwRwPAe108WwT9ubuuJJIrL68aNnQr4hFESz6P7SEX2bZy+y2yL37Gw==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.40.0" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz", + "integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter/node_modules/jose": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/oauth4webapi": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.3.tgz", + "integrity": "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -238,6 +427,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", + "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", + "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", + "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -923,6 +1150,18 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -971,6 +1210,920 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@prisma/client": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.10.1.tgz", + "integrity": "sha512-Re4pMlcUsQsUTAYMK7EJ4Bw2kg3WfZAAlr8GjORJaK4VOP6LxRQUQ1TuLnxcF42XqGkWQ36q5CQF1yVadANQ6w==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.10.1.tgz", + "integrity": "sha512-kz4/bnqrOrzWo8KzYguN0cden4CzLJJ+2VSpKtF8utHS3l1JS0Lhv6BLwpOX6X9yNreTbZQZwewb+/BMPDCIYQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/debug": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.10.1.tgz", + "integrity": "sha512-k2YT53cWxv9OLjW4zSYTZ6Z7j0gPfCzcr2Mj99qsuvlxr8WAKSZ2NcSR0zLf/mP4oxnYG842IMj3utTgcd7CaA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.10.1.tgz", + "integrity": "sha512-Q07P5rS2iPwk2IQr/rUQJ42tHjpPyFcbiH7PXZlV81Ryr9NYIgdxcUrwgVOWVm5T7ap02C0dNd1dpnNcSWig8A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1", + "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "@prisma/fetch-engine": "6.10.1", + "@prisma/get-platform": "6.10.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c.tgz", + "integrity": "sha512-ZJFTsEqapiTYVzXya6TUKYDFnSWCNegfUiG5ik9fleQva5Sk3DNyyUi7X1+0ZxWFHwHDr6BZV5Vm+iwP+LlciA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.10.1.tgz", + "integrity": "sha512-clmbG/Jgmrc/n6Y77QcBmAUlq9LrwI9Dbgy4pq5jeEARBpRCWJDJ7PWW1P8p0LfFU0i5fsyO7FqRzRB8mkdS4g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1", + "@prisma/engines-version": "6.10.1-1.9b628578b3b7cae625e8c927178f15a170e74a9c", + "@prisma/get-platform": "6.10.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.10.1.tgz", + "integrity": "sha512-4CY5ndKylcsce9Mv+VWp5obbR2/86SHOLVV053pwIkhVtT9C9A83yqiqI/5kJM9T1v1u1qco/bYjDKycmei9HA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.10.1" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1287,6 +2440,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1294,6 +2465,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/formidable": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.6.tgz", + "integrity": "sha512-LI4Hk+KNsM5q7br4oMVoaWeb+gUqJpz1N8+Y2Q6Cz9cVH33ybahRKUWaRmMboVlkwSbOUGgwc/pEkS7yMSzoWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1309,9 +2490,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.1.tgz", - "integrity": "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==", + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", "dev": true, "license": "MIT", "dependencies": { @@ -1322,7 +2503,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1332,7 +2513,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -1957,6 +3138,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2127,6 +3320,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2144,6 +3343,21 @@ "node": ">= 0.4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2170,6 +3384,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -2187,6 +3412,29 @@ "dev": true, "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2245,7 +3493,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2329,12 +3576,33 @@ "node": ">=18" } }, + "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/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "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/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2380,6 +3648,18 @@ "simple-swizzle": "^0.2.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2387,6 +3667,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2406,7 +3695,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2470,6 +3759,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -2531,6 +3830,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", @@ -2541,6 +3849,22 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2558,7 +3882,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -2663,7 +3986,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2673,7 +3995,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -2711,7 +4032,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -2724,7 +4044,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3267,6 +4586,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3280,6 +4605,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3331,6 +4668,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -3347,11 +4704,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3392,7 +4781,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3413,11 +4801,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -3505,7 +4901,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3584,7 +4979,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3597,7 +4991,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -3613,7 +5006,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4118,17 +5510,25 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "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/js-yaml": { @@ -4144,6 +5544,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4504,7 +5913,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4513,6 +5921,33 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-cache/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.523.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.523.0.tgz", + "integrity": "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -4527,7 +5962,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4557,6 +5991,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4721,6 +6176,57 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4749,16 +6255,76 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", + "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-mailjet": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/node-mailjet/-/node-mailjet-6.0.8.tgz", + "integrity": "sha512-VyB2+SeD1zuxpuJLePC4bk10UN0G294CsVlF8YBxb+1tlH0Tw4wECGlTYlQOaRMtPgckdObpcfEbhUWu3UuFIQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.1", + "json-bigint": "^1.0.0", + "url-join": "^4.0.0" + }, + "engines": { + "node": ">= 12.0.0", + "npm": ">= 6.9.0" + } + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", + "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4872,6 +6438,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5038,6 +6637,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5048,11 +6669,42 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.10.1.tgz", + "integrity": "sha512-khhlC/G49E4+uyA3T3H5PRBut486HD2bDqE2+rvkU0pwk9IAqGFacLFUyIx9Uw+W2eCtf6XGwsp+/strUwMNPw==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.10.1", + "@prisma/engines": "6.10.1" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5060,6 +6712,12 @@ "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5112,13 +6770,107 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -5513,6 +7265,16 @@ "is-arrayish": "^0.3.1" } }, + "node_modules/sonner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5736,6 +7498,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", @@ -5861,6 +7633,16 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.3.4.tgz", + "integrity": "sha512-dd1Ht6/YQHcNbq0znIT6dG8uhO7Ce+VIIhZUhjsryXsMPJQz3bZg7Q2eNzLwipb25bRZslGb2myio5mScd1TFg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5956,7 +7738,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -6037,6 +7819,77 @@ "punycode": "^2.1.0" } }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "license": "MIT" + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6152,6 +8005,12 @@ "node": ">=0.10.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", diff --git a/package.json b/package.json index 546781f..8ec5298 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,56 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint" }, "dependencies": { + "@auth/prisma-adapter": "^2.10.0", + "@prisma/client": "^6.10.1", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.7", + "bcrypt": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "fflate": "^0.8.2", + "formidable": "^3.5.4", + "lucide-react": "^0.523.0", + "next": "15.3.4", + "next-auth": "^4.24.11", + "next-themes": "^0.4.6", + "node-mailjet": "^6.0.8", "react": "^19.0.0", "react-dom": "^19.0.0", - "next": "15.3.4" + "react-dropzone": "^14.3.8", + "react-icons": "^5.5.0", + "sonner": "^2.0.5", + "tailwind-merge": "^3.3.1", + "uuid": "^11.1.0" }, "devDependencies": { - "typescript": "^5", - "@types/node": "^20", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", + "@types/bcrypt": "^6.0.0", + "@types/formidable": "^3.4.6", + "@types/node": "^20.19.26", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.3.4", - "@eslint/eslintrc": "^3" + "prisma": "^6.10.1", + "tailwindcss": "^4", + "tw-animate-css": "^1.3.4", + "typescript": "^5" } } diff --git a/pages/api/chunk-upload-v2.ts b/pages/api/chunk-upload-v2.ts new file mode 100644 index 0000000..6d53185 --- /dev/null +++ b/pages/api/chunk-upload-v2.ts @@ -0,0 +1,451 @@ +// pages/api/chunk-upload-v2.ts +// Server-side encryption approach: Upload raw chunks, encrypt on server, store encrypted + +import { NextApiRequest, NextApiResponse } from 'next'; +import { IncomingForm } from 'formidable'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { UPLOAD_DIR } from '@/lib/config'; +import { prisma } from '@/lib/prisma'; +import bcrypt from 'bcrypt'; +import { PLAN_CONFIG } from '@/lib/subscription'; +import { sendMailjetEmail } from '@/lib/mailjet'; +import { deriveKeyFromPassword, encryptChunkFrame, buildHeader } from '@/lib/server-encryption'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; +import crypto from 'crypto'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +interface UploadSession { + uploadId: string; + totalSize: number; + totalChunks: number; + receivedChunks: Set; + chunkSize: number; + sender: string; + recipient: string; + password?: string; + originalFilename: string; + message?: string; + filenames?: string; + createdAt: Date; + // Encryption metadata. There is deliberately no session-wide IV: each chunk + // frame carries its own, generated at encryption time. + salt: Buffer; + encryptionKey: Buffer; + receivedBytes: number; + maxBytes: number; +} + +// Store active upload sessions +const uploadSessions = new Map(); + +// Cleanup abandoned uploads every 5 minutes +const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours +setInterval(() => { + const now = Date.now(); + for (const [uploadId, session] of uploadSessions.entries()) { + if (now - session.createdAt.getTime() > SESSION_TIMEOUT) { + uploadSessions.delete(uploadId); + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + fsPromises.rm(chunkDir, { recursive: true }).catch(err => + console.warn(`Failed to clean abandoned upload ${uploadId}:`, err) + ); + } + } +}, 5 * 60 * 1000); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const authSession = await getServerSession(req, res, authOptions); + if (!authSession?.user?.email) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } + const sessionEmail = authSession.user.email; + + if (req.method === 'POST') { + return handleChunkUpload(req, res, sessionEmail); + } else if (req.method === 'GET') { + return handleStatusCheck(req, res, sessionEmail); + } else if (req.method === 'PUT') { + return handleChunkComplete(req, res, sessionEmail); + } + + return res.status(405).json({ success: false, message: 'Method not allowed' }); +} + +async function handleChunkUpload( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const form = new IncomingForm({ + uploadDir: path.join(UPLOAD_DIR, '.tmp'), + keepExtensions: true, + }); + + try { + await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true }); + } catch (err) { + console.error('Failed to create temp directory:', err); + } + + return new Promise((resolve) => { + form.parse(req, async (err: Error | null, fields: any, files: any) => { + if (err) { + return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message })); + } + + try { + const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk; + const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId; + const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex); + const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks); + const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize); + + if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) { + return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' })); + } + + // Get or create upload session + let session = uploadSessions.get(uploadId); + if (session && session.sender !== sessionEmail) { + return resolve(res.status(403).json({ success: false, message: 'Forbidden' })); + } + if (!session) { + const sender = sessionEmail; // never trust a sender field from the body + const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email; + const password = Array.isArray(fields.password) ? fields.password[0] : fields.password; + const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename; + const message = Array.isArray(fields.message) ? fields.message[0] : fields.message; + const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames; + + if (!recipient) { + return resolve(res.status(400).json({ success: false, message: 'Missing required fields' })); + } + + // A password is mandatory: it is the key material. Without it every + // file would be encrypted under a key derived from the empty string. + if (!password) { + return resolve(res.status(400).json({ success: false, message: 'A password is required' })); + } + + // Verify user exists + const user = await prisma.user.findUnique({ where: { email: sender } }); + if (!user) { + return resolve(res.status(404).json({ success: false, message: 'User not found' })); + } + + // Generate salt and derive encryption key + const salt = crypto.randomBytes(16); + const encryptionKey = await deriveKeyFromPassword(password, salt); + + const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; + const limits = PLAN_CONFIG[plan]; + + session = { + uploadId, + totalSize: chunkSize * totalChunks, + totalChunks, + receivedChunks: new Set(), + chunkSize, + sender, + recipient, + password, + originalFilename, + message, + filenames, + createdAt: new Date(), + salt, + encryptionKey, + receivedBytes: 0, + maxBytes: limits.maxFileSize, + }; + + // The advertised size is client-supplied, so this is only an early + // reject; the real enforcement is the running receivedBytes check. + if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) { + return resolve(res.status(413).json({ + success: false, + message: `File exceeds max size for your plan (${plan})` + })); + } + + uploadSessions.set(uploadId, session); + } + + // Read and encrypt chunk into a self-contained frame (own IV + own tag) + const chunkData = await fsPromises.readFile(chunkFile.filepath); + + if ( + session.maxBytes !== Infinity && + session.receivedBytes + chunkData.length > session.maxBytes + ) { + await fsPromises.unlink(chunkFile.filepath).catch(() => {}); + return resolve(res.status(413).json({ + success: false, + message: 'File exceeds max size for your plan', + })); + } + + const frame = encryptChunkFrame(chunkData, session.encryptionKey); + + // Save encrypted chunk to temp location + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + await fsPromises.mkdir(chunkDir, { recursive: true }); + const encryptedChunkPath = path.join(chunkDir, `chunk-${chunkIndex}.enc`); + + await fsPromises.writeFile(encryptedChunkPath, frame); + + // Clean up formidable temp file + try { + await fsPromises.unlink(chunkFile.filepath); + } catch (err) { + console.warn('Failed to clean up temp file:', err); + } + + // Guard against a retried chunk double-counting toward the quota. + if (!session.receivedChunks.has(chunkIndex)) { + session.receivedBytes += chunkData.length; + } + session.receivedChunks.add(chunkIndex); + + return resolve(res.status(200).json({ + success: true, + uploadId, + chunkIndex, + receivedChunks: Array.from(session.receivedChunks), + })); + } catch (err: any) { + console.error('Chunk upload error:', err); + return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message })); + } + }); + }); +} + +async function handleStatusCheck( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const { uploadId } = req.query; + + if (!uploadId || typeof uploadId !== 'string') { + return res.status(400).json({ success: false, message: 'Missing uploadId' }); + } + + const session = uploadSessions.get(uploadId); + if (!session || session.sender !== sessionEmail) { + return res.status(404).json({ success: false, message: 'Upload session not found' }); + } + + return res.status(200).json({ + success: true, + uploadId, + totalChunks: session.totalChunks, + receivedChunks: Array.from(session.receivedChunks), + isComplete: session.receivedChunks.size === session.totalChunks, + receivedBytes: session.receivedBytes, + totalBytes: session.totalSize, + }); +} + +async function handleChunkComplete( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const { uploadId } = req.query; + + if (!uploadId || typeof uploadId !== 'string') { + return res.status(400).json({ success: false, message: 'Missing uploadId' }); + } + + const session = uploadSessions.get(uploadId); + if (!session || session.sender !== sessionEmail) { + return res.status(404).json({ success: false, message: 'Upload session not found' }); + } + + // Check if all chunks received + if (session.receivedChunks.size !== session.totalChunks) { + return res.status(400).json({ + success: false, + message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`, + }); + } + + try { + // Reassemble encrypted chunks into final encrypted file + console.log(`[${uploadId}] Starting reassembly of ${session.totalChunks} encrypted chunks`); + + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + const finalFilename = uuidv4() + '.enc'; + const finalPath = path.join(UPLOAD_DIR, finalFilename); + + const writeStream = fs.createWriteStream(finalPath); + + // Write the format header: magic (8) + salt (16). The per-chunk IVs live + // in the frames themselves, so nothing needs storing in the DB. + writeStream.write(buildHeader(session.salt)); + + // Assemble encrypted frames in order, with progress tracking + let chunksAssembled = 0; + for (let i = 0; i < session.totalChunks; i++) { + const chunkPath = path.join(chunkDir, `chunk-${i}.enc`); + + // Retry reading with exponential backoff + let chunkData: Buffer | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + chunkData = await fsPromises.readFile(chunkPath); + break; + } catch (err: any) { + if (attempt < 2 && err.code === 'EACCES') { + await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100)); + } else { + throw err; + } + } + } + + // A silently skipped chunk would corrupt the file, so fail loudly. + if (!chunkData) { + throw new Error(`Chunk ${i} missing during reassembly`); + } + + writeStream.write(chunkData); + chunksAssembled++; + + if (chunksAssembled % 10 === 0 || chunksAssembled === session.totalChunks) { + console.log(`[${uploadId}] Reassembly progress: ${chunksAssembled}/${session.totalChunks} chunks`); + } + } + + console.log(`[${uploadId}] Waiting for write stream to finish...`); + + await new Promise((resolve, reject) => { + writeStream.on('finish', resolve); + writeStream.on('error', reject); + writeStream.end(); + }); + + console.log(`[${uploadId}] Reassembly complete, validating file...`); + + // Sizes recorded and metered are PLAINTEXT bytes actually received, not the + // on-disk size, which is inflated by the header and per-frame IVs and tags. + const plaintextSize = session.receivedBytes; + + // Verify user and check monthly limits + const user = await prisma.user.findUnique({ where: { email: session.sender } }); + if (!user) { + await fsPromises.unlink(finalPath); + return res.status(404).json({ success: false, message: 'User not found' }); + } + + const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; + const limits = PLAN_CONFIG[plan]; + + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const transfersThisMonth = await prisma.transfer.findMany({ + where: { + senderEmail: session.sender, + createdAt: { gte: startOfMonth }, + }, + }); + + const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0)); + if ( + (limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) || + (limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(plaintextSize) > BigInt(limits.maxTransferSizePerMonth)) + ) { + await fsPromises.unlink(finalPath); + return res.status(429).json({ success: false, message: 'Plan limits exceeded' }); + } + + // Hash password for verification (don't encrypt again) + const hash = session.password ? await bcrypt.hash(session.password, 10) : null; + + // Parse filenames + let totalFiles = 1; + if (session.filenames) { + try { + const filesArray = JSON.parse(session.filenames); + if (Array.isArray(filesArray)) { + totalFiles = filesArray.length; + } + } catch {} + } + + const expiresAt = new Date(Date.now() + limits.maxExpiryMs); + + // Create transfer record + const transfer = await prisma.transfer.create({ + data: { + senderEmail: session.sender, + recipientEmail: session.recipient, + passwordHash: hash, + downloadUrl: uuidv4(), + expiresAt, + filenames: session.filenames || null, + message: session.message || null, + totalFiles, + totalSize: plaintextSize, + userId: user.id, + }, + }); + + // Create file record with encryption metadata + // Store salt and IV in the file path (prepended to encrypted data) + // So we don't need separate DB fields + await prisma.file.create({ + data: { + name: session.originalFilename || 'file', + path: finalPath, + size: plaintextSize, + transferId: transfer.id, + }, + }); + + // Send email + const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`; + + await sendMailjetEmail({ + to: session.recipient, + subject: 'You\'ve received an encrypted file', + text: ` +${session.sender} sent you an encrypted file via TransferTribe. + +Link: ${link} + +${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it. +`.trim(), + }); + + // Clean up session and temp files + uploadSessions.delete(uploadId); + try { + await fsPromises.rm(chunkDir, { recursive: true }); + } catch (err) { + console.warn('Failed to clean up temp directory:', err); + } + + return res.status(200).json({ + success: true, + message: 'Upload complete', + downloadUrl: transfer.downloadUrl, + }); + } catch (err: any) { + console.error('Chunk completion error:', err); + return res.status(500).json({ success: false, message: 'Completion error: ' + err.message }); + } +} diff --git a/pages/api/chunk-upload.ts b/pages/api/chunk-upload.ts new file mode 100644 index 0000000..d36ca28 --- /dev/null +++ b/pages/api/chunk-upload.ts @@ -0,0 +1,412 @@ +// pages/api/chunk-upload.ts +// Chunked upload endpoint for large files with encryption + +import { NextApiRequest, NextApiResponse } from 'next'; +import { IncomingForm } from 'formidable'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; +import { UPLOAD_DIR } from '@/lib/config'; +import { prisma } from '@/lib/prisma'; +import bcrypt from 'bcrypt'; +import { PLAN_CONFIG } from '@/lib/subscription'; +import { sendMailjetEmail } from '@/lib/mailjet'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +interface ChunkMetadata { + uploadId: string; + chunkIndex: number; + totalChunks: number; + chunkSize: number; +} + +interface UploadSession { + uploadId: string; + totalSize: number; + totalChunks: number; + receivedChunks: Set; + chunkSize: number; + sender: string; + recipient: string; + password?: string; + encryptionIv: string; + originalFilename: string; + message?: string; + filenames?: string; + createdAt: Date; +} + +// Store active upload sessions in memory (in production, use Redis) +const uploadSessions = new Map(); + +// Cleanup abandoned uploads every 5 minutes (default 24h timeout) +const SESSION_TIMEOUT = 24 * 60 * 60 * 1000; // 24 hours +setInterval(() => { + const now = Date.now(); + for (const [uploadId, session] of uploadSessions.entries()) { + if (now - session.createdAt.getTime() > SESSION_TIMEOUT) { + uploadSessions.delete(uploadId); + // Clean up temp files + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + fsPromises.rm(chunkDir, { recursive: true }).catch(err => + console.warn(`Failed to clean abandoned upload ${uploadId}:`, err) + ); + } + } +}, 5 * 60 * 1000); // Run every 5 minutes + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const session = await getServerSession(req, res, authOptions); + if (!session?.user?.email) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } + const sessionEmail = session.user.email; + + if (req.method === 'POST') { + return handleChunkUpload(req, res, sessionEmail); + } else if (req.method === 'GET') { + return handleStatusCheck(req, res, sessionEmail); + } else if (req.method === 'PUT') { + return handleChunkComplete(req, res, sessionEmail); + } + + return res.status(405).json({ success: false, message: 'Method not allowed' }); +} + +async function handleChunkUpload( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const form = new IncomingForm({ + uploadDir: path.join(UPLOAD_DIR, '.tmp'), + keepExtensions: true, + }); + + // Ensure temp directory exists + try { + await fsPromises.mkdir(path.join(UPLOAD_DIR, '.tmp'), { recursive: true }); + } catch (err) { + console.error('Failed to create temp directory:', err); + } + + return new Promise((resolve) => { + form.parse(req, async (err: Error | null, fields: any, files: any) => { + if (err) { + return resolve(res.status(400).json({ success: false, message: 'Parse error: ' + err.message })); + } + + try { + const chunkFile = Array.isArray(files.chunk) ? files.chunk[0] : files.chunk; + const uploadId = Array.isArray(fields.uploadId) ? fields.uploadId[0] : fields.uploadId; + const chunkIndex = parseInt(Array.isArray(fields.chunkIndex) ? fields.chunkIndex[0] : fields.chunkIndex); + const totalChunks = parseInt(Array.isArray(fields.totalChunks) ? fields.totalChunks[0] : fields.totalChunks); + const chunkSize = parseInt(Array.isArray(fields.chunkSize) ? fields.chunkSize[0] : fields.chunkSize); + + if (!chunkFile || !uploadId || isNaN(chunkIndex) || isNaN(totalChunks)) { + return resolve(res.status(400).json({ success: false, message: 'Missing chunk metadata' })); + } + + // Get or create upload session + let session = uploadSessions.get(uploadId); + if (session && session.sender !== sessionEmail) { + return resolve(res.status(403).json({ success: false, message: 'Forbidden' })); + } + if (!session) { + const sender = sessionEmail; + const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email; + const password = Array.isArray(fields.password) ? fields.password[0] : fields.password; + const encryptionIv = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv; + const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename; + const message = Array.isArray(fields.message) ? fields.message[0] : fields.message; + const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames; + + if (!sender || !recipient || !encryptionIv) { + return resolve(res.status(400).json({ success: false, message: 'Missing required fields' })); + } + + // Verify user exists + const user = await prisma.user.findUnique({ where: { email: sender } }); + if (!user) { + return resolve(res.status(404).json({ success: false, message: 'User not found' })); + } + + session = { + uploadId, + totalSize: chunkSize * totalChunks, + totalChunks, + receivedChunks: new Set(), + chunkSize, + sender, + recipient, + password, + encryptionIv, + originalFilename, + message, + filenames, + createdAt: new Date(), + }; + + // Check plan limits before accepting upload + const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; + const limits = PLAN_CONFIG[plan]; + + if (limits.maxFileSize !== Infinity && session.totalSize > limits.maxFileSize) { + return resolve(res.status(413).json({ + success: false, + message: `File exceeds max size for your plan (${plan})` + })); + } + + uploadSessions.set(uploadId, session); + } + + // Save chunk to temp location + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + await fsPromises.mkdir(chunkDir, { recursive: true }); + const chunkPath = path.join(chunkDir, `chunk-${chunkIndex}`); + + await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(chunkFile.filepath); + const writeStream = fs.createWriteStream(chunkPath); + + readStream.on('error', reject); + writeStream.on('error', reject); + writeStream.on('finish', resolve); + + readStream.pipe(writeStream); + }); + + // Clean up formidable temp file + try { + await fsPromises.unlink(chunkFile.filepath); + } catch (err) { + console.warn('Failed to clean up temp file:', err); + } + + session.receivedChunks.add(chunkIndex); + + return resolve(res.status(200).json({ + success: true, + uploadId, + chunkIndex, + receivedChunks: Array.from(session.receivedChunks), + })); + } catch (err: any) { + console.error('Chunk upload error:', err); + return resolve(res.status(500).json({ success: false, message: 'Upload error: ' + err.message })); + } + }); + }); +} + +async function handleStatusCheck( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const { uploadId } = req.query; + + if (!uploadId || typeof uploadId !== 'string') { + return res.status(400).json({ success: false, message: 'Missing uploadId' }); + } + + const session = uploadSessions.get(uploadId); + if (!session || session.sender !== sessionEmail) { + return res.status(404).json({ success: false, message: 'Upload session not found' }); + } + + return res.status(200).json({ + success: true, + uploadId, + totalChunks: session.totalChunks, + receivedChunks: Array.from(session.receivedChunks), + isComplete: session.receivedChunks.size === session.totalChunks, + }); +} + +async function handleChunkComplete( + req: NextApiRequest, + res: NextApiResponse, + sessionEmail: string +) { + const { uploadId } = req.query; + + if (!uploadId || typeof uploadId !== 'string') { + return res.status(400).json({ success: false, message: 'Missing uploadId' }); + } + + const session = uploadSessions.get(uploadId); + if (!session || session.sender !== sessionEmail) { + return res.status(404).json({ success: false, message: 'Upload session not found' }); + } + + // Check if all chunks received + if (session.receivedChunks.size !== session.totalChunks) { + return res.status(400).json({ + success: false, + message: `Missing chunks. Received ${session.receivedChunks.size}/${session.totalChunks}`, + }); + } + + try { + // Reassemble chunks into final file + const chunkDir = path.join(UPLOAD_DIR, '.tmp', uploadId); + const finalFilename = uuidv4() + '.enc'; + const finalPath = path.join(UPLOAD_DIR, finalFilename); + + const writeStream = fs.createWriteStream(finalPath); + + for (let i = 0; i < session.totalChunks; i++) { + const chunkPath = path.join(chunkDir, `chunk-${i}`); + // Retry reading with exponential backoff to handle file locks + let chunkData: Buffer | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + chunkData = await fsPromises.readFile(chunkPath); + break; + } catch (err: any) { + if (attempt < 2 && err.code === 'EACCES') { + await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 100)); + } else { + throw err; + } + } + } + if (chunkData) { + writeStream.write(chunkData); + } + } + + await new Promise((resolve, reject) => { + writeStream.on('finish', resolve); + writeStream.on('error', reject); + writeStream.end(); + }); + + // Get final file size + const stats = await fsPromises.stat(finalPath); + + // Verify user and check monthly limits + const user = await prisma.user.findUnique({ where: { email: session.sender } }); + if (!user) { + await fsPromises.unlink(finalPath); + return res.status(404).json({ success: false, message: 'User not found' }); + } + + const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; + const limits = PLAN_CONFIG[plan]; + + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const transfersThisMonth = await prisma.transfer.findMany({ + where: { + senderEmail: session.sender, + createdAt: { gte: startOfMonth }, + }, + }); + + const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0)); + if ( + (limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth) || + (limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth)) + ) { + await fsPromises.unlink(finalPath); + return res.status(429).json({ success: false, message: 'Plan limits exceeded' }); + } + + // Hash password + const hash = session.password ? await bcrypt.hash(session.password, 10) : null; + + // Parse filenames + let totalFiles = 1; + if (session.filenames) { + try { + const filesArray = JSON.parse(session.filenames); + if (Array.isArray(filesArray)) { + totalFiles = filesArray.length; + } + } catch {} + } + + const expiresAt = new Date(Date.now() + limits.maxExpiryMs); + + // Create transfer record + const transfer = await prisma.transfer.create({ + data: { + senderEmail: session.sender, + recipientEmail: session.recipient, + passwordHash: hash, + downloadUrl: uuidv4(), + expiresAt, + filenames: session.filenames || null, + message: session.message || null, + totalFiles, + totalSize: stats.size, + userId: user.id, + }, + }); + + // Create file record separately + await prisma.file.create({ + data: { + name: session.originalFilename || 'file', + path: finalPath, + size: stats.size, + transferId: transfer.id, + }, + }); + + // Update transfer with encryption IV in filenames field (temporary workaround) + // TODO: Fix Prisma type generation for encryptionIv field + if (session.encryptionIv) { + // Store IV in a separate metadata approach or update via raw query + await (prisma as any).$executeRaw` + UPDATE "File" SET "encryptionIv" = ${session.encryptionIv} + WHERE "transferId" = ${transfer.id} + `; + } + + // Send email + const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`; + + await sendMailjetEmail({ + to: session.recipient, + subject: 'You\'ve received an encrypted file', + text: ` +${session.sender} sent you an encrypted file via TransferTribe. + +Link: ${link} + +${session.message ? `Message:\n${session.message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it. +`.trim(), + }); + + // Clean up session and temp files + uploadSessions.delete(uploadId); + try { + await fsPromises.rm(chunkDir, { recursive: true }); + } catch (err) { + console.warn('Failed to clean up temp directory:', err); + } + + return res.status(200).json({ + success: true, + message: 'Upload complete', + downloadUrl: transfer.downloadUrl, + }); + } catch (err: any) { + console.error('Chunk completion error:', err); + return res.status(500).json({ success: false, message: 'Completion error: ' + err.message }); + } +} diff --git a/pages/api/send.ts b/pages/api/send.ts new file mode 100644 index 0000000..6364730 --- /dev/null +++ b/pages/api/send.ts @@ -0,0 +1,211 @@ +// pages/api/send.ts +import { prisma } from '@/lib/prisma'; +import { UPLOAD_DIR } from '@/lib/config'; +import { PLAN_CONFIG } from '@/lib/subscription'; +import { sendMailjetEmail } from '@/lib/mailjet'; +import { v4 as uuidv4 } from 'uuid'; +import bcrypt from 'bcrypt'; +import path from 'path'; +import { IncomingForm, File as FormidableFile } from 'formidable'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { getServerSession } from 'next-auth/next'; +import { authOptions } from '@/lib/auth'; + +export const config = { + api: { + bodyParser: false, + }, +}; + +interface ParsedFields { + [key: string]: string | string[] | undefined; +} + +interface ParsedFiles { + [key: string]: FormidableFile | FormidableFile[] | undefined; +} + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== 'POST') { + return res.status(405).json({ success: false, message: 'Method not allowed' }); + } + + const session = await getServerSession(req, res, authOptions); + if (!session?.user?.email) { + return res.status(401).json({ success: false, message: 'Unauthorized' }); + } + + const form = new IncomingForm({ + uploadDir: UPLOAD_DIR, + keepExtensions: true, + maxFileSize: 100 * 1024 * 1024 * 1024, // 100GB + }); + + const parseForm = (): Promise<{ fields: ParsedFields; files: ParsedFiles }> => + new Promise((resolve, reject) => { + form.parse(req, (err: Error | null, fields: any, files: any) => { + if (err) return reject(err); + resolve({ fields, files }); + }); + }); + + try { + const { fields, files } = await parseForm(); + + // Extract form fields + const encryptedFile = Array.isArray(files.file) ? files.file[0] : files.file; + const recipient = Array.isArray(fields.email) ? fields.email[0] : fields.email; + const password = Array.isArray(fields.password) ? fields.password[0] : fields.password; + // Sender is taken from the session, never the request body, so a caller + // cannot post transfers (and consume quota) as another user. + const sender = session.user.email; + const filenames = Array.isArray(fields.filenames) ? fields.filenames[0] : fields.filenames; + const message = Array.isArray(fields.message) ? fields.message[0] : fields.message; + const encryptionIvStr = Array.isArray(fields.encryptionIv) ? fields.encryptionIv[0] : fields.encryptionIv; + const originalFilename = Array.isArray(fields.originalFilename) ? fields.originalFilename[0] : fields.originalFilename; + + if (!encryptedFile || !recipient || !sender) { + return res.status(400).json({ success: false, message: 'Missing required fields' }); + } + + // Ensure upload directory exists + if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + } + + // Generate final filename and move encrypted file + const finalFilename = uuidv4() + '.enc'; + const finalPath = path.join(UPLOAD_DIR, finalFilename); + + // Use streaming copy for large files to avoid permission issues + await new Promise((resolve, reject) => { + const readStream = fs.createReadStream(encryptedFile.filepath); + const writeStream = fs.createWriteStream(finalPath); + + readStream.on('error', reject); + writeStream.on('error', reject); + writeStream.on('finish', resolve); + + readStream.pipe(writeStream); + }); + + // Clean up temp file after successful copy + try { + await fsPromises.unlink(encryptedFile.filepath); + } catch (err) { + console.warn('Failed to clean up temp file:', err); + } + + // Verify user exists + const user = await prisma.user.findUnique({ where: { email: sender } }); + if (!user) { + fs.unlinkSync(finalPath); + return res.status(404).json({ success: false, message: 'User not found' }); + } + + const plan = (user.plan || 'free') as 'free' | 'rookie' | 'pro'; + const limits = PLAN_CONFIG[plan]; + + // Check file size against plan limits + const stats = fs.statSync(finalPath); + if (limits.maxFileSize !== Infinity && stats.size > limits.maxFileSize) { + fs.unlinkSync(finalPath); + return res.status(413).json({ + success: false, + message: `File exceeds max size for your plan (${plan})` + }); + } + + // Check monthly transfer limits + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const transfersThisMonth = await prisma.transfer.findMany({ + where: { + senderEmail: sender, + createdAt: { gte: startOfMonth }, + }, + }); + + const totalSizeSentThisMonth = transfersThisMonth.reduce((acc, t) => acc + BigInt(t.totalSize), BigInt(0)); + if ( + limits.maxTransfersPerMonth !== Infinity && transfersThisMonth.length >= limits.maxTransfersPerMonth || + limits.maxTransferSizePerMonth !== Infinity && totalSizeSentThisMonth + BigInt(stats.size) > BigInt(limits.maxTransferSizePerMonth) + ) { + fs.unlinkSync(finalPath); + return res.status(429).json({ success: false, message: 'Plan limits exceeded' }); + } + + // Hash password if provided + const hash = password ? await bcrypt.hash(password, 10) : null; + + // Parse filenames if provided + let totalFiles = 1; + if (filenames) { + try { + const filesArray = JSON.parse(filenames); + if (Array.isArray(filesArray)) { + totalFiles = filesArray.length; + } + } catch {} + } + + const expiresAt = new Date(Date.now() + limits.maxExpiryMs); + + // Create transfer record with encrypted file + const transfer = await prisma.transfer.create({ + data: { + senderEmail: sender, + recipientEmail: recipient, + passwordHash: hash, + downloadUrl: uuidv4(), + expiresAt, + filenames: filenames || null, + message: message || null, + totalFiles, + totalSize: stats.size, + userId: user?.id, + files: { + create: [ + { + name: originalFilename || 'file', + path: finalPath, + size: stats.size, + }, + ], + }, + }, + }); + + // Update file with encryption IV using raw query + if (encryptionIvStr) { + await (prisma as any).$executeRaw` + UPDATE "File" SET "encryptionIv" = ${encryptionIvStr} + WHERE "transferId" = ${transfer.id} + `; + } + + // Send email notification + const link = `${process.env.NEXT_PUBLIC_APP_URL}/download/${transfer.downloadUrl}`; + + await sendMailjetEmail({ + to: recipient, + subject: 'You\'ve received an encrypted file', + text: ` +${sender} sent you an encrypted file via TransferTribe. + +Link: ${link} + +${message ? `Message:\n${message}\n\n` : ''}Note: You'll need the password they shared with you to decrypt it. +`.trim(), + }); + + return res.status(200).json({ success: true }); + } catch (err: any) { + console.error('Upload failed:', err); + return res.status(500).json({ success: false, message: 'Upload failed: ' + err.message }); + } +} diff --git a/prisma/migrations/add_encryption_iv/migration.sql b/prisma/migrations/add_encryption_iv/migration.sql new file mode 100644 index 0000000..4285985 --- /dev/null +++ b/prisma/migrations/add_encryption_iv/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "File" ADD COLUMN "encryptionIv" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..2f27a3e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,110 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum TransferStatus { + ACTIVE + EXPIRED + DELETED +} + +model User { + id String @id @default(cuid()) + name String? + email String? @unique + emailVerified DateTime? + image String? + plan String @default("free") // "free", "rookie", or "pro" + + // Optional + transfers Transfer[] @relation("UserTransfers") + accounts Account[] + sessions Session[] +} + +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +model Transfer { + id Int @id @default(autoincrement()) + senderEmail String + recipientEmail String + passwordHash String? + downloadUrl String @unique + createdAt DateTime @default(now()) + expiresAt DateTime + files File[] + filenames String? // JSON stringified array of filenames + totalFiles Int @default(0) // new field to track number of files in transfer + totalSize BigInt @default(0) // Use BigInt to support files > 2GB + message String? // optional message + status TransferStatus @default(ACTIVE) // Added + + userId String? // Change from Int? to String? + user User? @relation("UserTransfers", fields: [userId], references: [id]) +} + + +model File { + id Int @id @default(autoincrement()) + name String + path String + size BigInt // Use BigInt to support files > 2GB + encryptionIv String? // 12-byte IV as base64 string for AES-GCM decryption + transferId Int + transfer Transfer @relation(fields: [transferId], references: [id]) + downloads FileDownload[] // New +} + +model FileDownload { + id Int @id @default(autoincrement()) + fileId Int + file File @relation(fields: [fileId], references: [id], onDelete: Cascade) + timestamp DateTime @default(now()) + ip String? + userAgent String? +} diff --git a/public/logo.psd b/public/logo.psd new file mode 100644 index 0000000..9b6216e Binary files /dev/null and b/public/logo.psd differ diff --git a/public/logo2.psd b/public/logo2.psd new file mode 100644 index 0000000..4c0b204 Binary files /dev/null and b/public/logo2.psd differ diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..c95cce8 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,7 @@ +// app/api/auth/[...nextauth]/route.ts +import NextAuth from 'next-auth' +import { authOptions } from '@/lib/auth' + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/src/app/api/download/route.ts b/src/app/api/download/route.ts new file mode 100644 index 0000000..a4cee40 --- /dev/null +++ b/src/app/api/download/route.ts @@ -0,0 +1,51 @@ +import { prisma } from '@/lib/prisma' +import { NextResponse } from 'next/server' +import { TransferStatus } from '@prisma/client' + +/** + * Pre-authentication metadata for the download page. + * + * This runs BEFORE the visitor has proved they know the password, so it returns + * only what the landing screen needs to render. Filenames, the sender's + * message, the recipient address and the on-disk file paths are withheld until + * /api/verify succeeds. + */ +export async function GET(req: Request) { + const url = new URL(req.url) + const id = url.searchParams.get('id') + + if (!id) { + return NextResponse.json({ success: false, message: 'No ID provided' }, { status: 400 }) + } + + const transfer = await prisma.transfer.findUnique({ + where: { downloadUrl: id }, + include: { files: true }, + }) + + if (!transfer) { + return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 }) + } + + const isExpired = transfer.expiresAt <= new Date() + + if (transfer.status !== TransferStatus.ACTIVE || isExpired) { + return NextResponse.json( + { success: false, message: 'This transfer is no longer available' }, + { status: 410 } + ) + } + + const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0)) + + return NextResponse.json({ + success: true, + transfer: { + senderEmail: transfer.senderEmail, + fileCount: transfer.files.length, + totalSize: Number(totalSize), + expiresAt: transfer.expiresAt.toISOString(), + requiresPassword: Boolean(transfer.passwordHash), + }, + }) +} diff --git a/src/app/api/file/[id]/route.ts b/src/app/api/file/[id]/route.ts new file mode 100644 index 0000000..80b3e0b --- /dev/null +++ b/src/app/api/file/[id]/route.ts @@ -0,0 +1,150 @@ +import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcrypt' +import fs from 'fs/promises' +import { TransferStatus } from '@prisma/client' +import { decryptFileStream } from '@/lib/server-encryption' + +// Node APIs (fs handles, crypto) — this route cannot run on the edge runtime. +export const runtime = 'nodejs' + +/** + * Build a Content-Disposition value that survives quotes, newlines and + * non-ASCII characters in a user-supplied filename. + */ +function contentDisposition(filename: string): string { + const fallback = filename.replace(/[^\w.\-]+/g, '_') || 'download' + const encoded = encodeURIComponent(filename) + return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}` +} + +/** + * POST (not GET) so the password travels in the body — as a query parameter it + * would be recorded in access logs, proxy logs and Referer headers. + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + + if (!id) { + return NextResponse.json({ error: 'Missing ID' }, { status: 400 }) + } + + let password = '' + try { + const body = await req.json() + password = typeof body?.password === 'string' ? body.password : '' + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + if (!password) { + return NextResponse.json({ error: 'Password required' }, { status: 401 }) + } + + const transfer = await prisma.transfer.findUnique({ + where: { downloadUrl: id }, + include: { files: true }, + }) + + if (!transfer || !transfer.files.length) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + // Expiry and soft-deletion are enforced here, not just displayed in the UI. + if (transfer.status !== TransferStatus.ACTIVE) { + return NextResponse.json({ error: 'This transfer is no longer available' }, { status: 410 }) + } + + if (transfer.expiresAt <= new Date()) { + if (transfer.status === TransferStatus.ACTIVE) { + await prisma.transfer.update({ + where: { id: transfer.id }, + data: { status: TransferStatus.EXPIRED }, + }) + } + return NextResponse.json({ error: 'This transfer has expired' }, { status: 410 }) + } + + // Verify the password against the stored hash BEFORE touching the file or + // recording a download. The GCM tag check later is a second, independent gate. + if (!transfer.passwordHash) { + return NextResponse.json({ error: 'This transfer is not downloadable' }, { status: 409 }) + } + + const passwordValid = await bcrypt.compare(password, transfer.passwordHash) + if (!passwordValid) { + return NextResponse.json({ error: 'Incorrect password' }, { status: 401 }) + } + + const file = transfer.files[0] + + try { + await fs.access(file.path) + } catch { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // Only now, after a successful authentication, does this count as a download. + await prisma.fileDownload.create({ + data: { + fileId: file.id, + ip: req.headers.get('x-forwarded-for') || req.headers.get('x-real-ip') || null, + userAgent: req.headers.get('user-agent') || null, + }, + }) + + // Probe the first frame before committing to a 200, so a corrupt or + // legacy-format file produces a proper status code rather than a truncated body. + const frames = decryptFileStream(file.path, password) + let firstFrame: Buffer | undefined + try { + const first = await frames.next() + if (!first.done) firstFrame = first.value + } catch (err: any) { + await frames.return(undefined as never).catch(() => {}) + if (err?.message === 'UNSUPPORTED_FORMAT') { + console.error(`Legacy-format file for transfer ${transfer.id}: ${file.path}`) + return NextResponse.json( + { error: 'This transfer was created with an older, incompatible version and cannot be decrypted. Please ask the sender to resend it.' }, + { status: 422 } + ) + } + console.error('Decrypt error:', err) + return NextResponse.json({ error: 'Unable to decrypt this file' }, { status: 422 }) + } + + // Stream the remaining frames so large transfers are never fully buffered. + const stream = new ReadableStream({ + async start(controller) { + if (firstFrame) controller.enqueue(new Uint8Array(firstFrame)) + }, + async pull(controller) { + try { + const next = await frames.next() + if (next.done) { + controller.close() + return + } + controller.enqueue(new Uint8Array(next.value)) + } catch (err) { + console.error('Decrypt error mid-stream:', err) + controller.error(err) + } + }, + async cancel() { + await frames.return(undefined as never).catch(() => {}) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': contentDisposition(file.name), + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts new file mode 100644 index 0000000..2a18642 --- /dev/null +++ b/src/app/api/stats/route.ts @@ -0,0 +1,72 @@ +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { NextResponse } from 'next/server'; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const email = session.user.email; + + // Transfers sent + const transfersSent = await prisma.transfer.count({ + where: { senderEmail: email }, + }); + + // Total files sent - sum of totalFiles field on transfers + const totalFilesData = await prisma.transfer.aggregate({ + where: { senderEmail: email }, + _sum: { + totalFiles: true, + }, + }); + const totalFilesSent = totalFilesData._sum.totalFiles ?? 0; + + // Downloads - count of fileDownload for files in transfers by this sender + const downloads = await prisma.fileDownload.count({ + where: { + file: { + transfer: { + senderEmail: email, + }, + }, + }, + }); + + // Active transfers - example: transfers not expired yet + const activeTransfers = await prisma.transfer.count({ + where: { + senderEmail: email, + expiresAt: { + gt: new Date(), + }, + }, + }); + + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const transfersThisMonth = await prisma.transfer.count({ + where: { + senderEmail: email, + createdAt: { + gte: startOfMonth, + }, + }, + }); + + const remainingTransfers = Math.max(10 - transfersThisMonth, 0); + + return NextResponse.json({ + transfersSent, + totalFilesSent, + downloads, + activeTransfers, + remainingTransfers, + }); +} diff --git a/src/app/api/transfers/[id]/resend/route.ts b/src/app/api/transfers/[id]/resend/route.ts new file mode 100644 index 0000000..755c20d --- /dev/null +++ b/src/app/api/transfers/[id]/resend/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { sendMailjetEmail } from '@/lib/mailjet' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + const session = await getServerSession(authOptions) + if (!session?.user?.email) { + return NextResponse.json({ success: false, message: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await context.params + const transferId = parseInt(id, 10) + + if (isNaN(transferId)) { + return NextResponse.json({ success: false, message: 'Invalid ID' }, { status: 400 }) + } + + // Scoped to the sender: without this, any signed-in user could walk the + // autoincrement IDs and make the app mail arbitrary recipients. + const transfer = await prisma.transfer.findFirst({ + where: { id: transferId, senderEmail: session.user.email }, + }) + + if (!transfer) { + return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 }) + } + + if (transfer.status !== 'ACTIVE') { + return NextResponse.json({ success: false, message: 'Transfer is not active' }, { status: 400 }) + } + + if (transfer.expiresAt <= new Date()) { + return NextResponse.json({ success: false, message: 'Transfer has expired' }, { status: 400 }) + } + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://transfertribe.com' + const link = `${baseUrl}/download/${transfer.downloadUrl}` + + await sendMailjetEmail({ + to: transfer.recipientEmail, + subject: 'Transfer resent: Your file is still available', + text: ` +${transfer.senderEmail} resent you a file via TransferTribe. + +Link: ${link} + +${transfer.message ? `Message:\n${transfer.message}\n\n` : ''}Note: You'll need the password they shared with you to download it. +`.trim(), + }) + + return NextResponse.json({ success: true, message: 'Resent successfully' }) +} diff --git a/src/app/api/transfers/[id]/route.ts b/src/app/api/transfers/[id]/route.ts new file mode 100644 index 0000000..88b6eed --- /dev/null +++ b/src/app/api/transfers/[id]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +// Resolves the transfer only if the signed-in user owns it. Returns null for +// both "missing" and "not yours" so the response can't be used to enumerate IDs. +async function findOwnedTransfer(id: string) { + const session = await getServerSession(authOptions); + if (!session?.user?.email) return { transfer: null, unauthenticated: true }; + + const transferId = Number(id); + if (!Number.isInteger(transferId)) return { transfer: null, unauthenticated: false }; + + const transfer = await prisma.transfer.findFirst({ + where: { id: transferId, senderEmail: session.user.email }, + include: { files: { include: { downloads: true } } }, + }); + + return { transfer, unauthenticated: false }; +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { transfer, unauthenticated } = await findOwnedTransfer(id); + + if (unauthenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!transfer) { + return NextResponse.json({ error: 'Transfer not found' }, { status: 404 }); + } + + // Soft delete: mark status as DELETED + await prisma.transfer.update({ + where: { id: transfer.id }, + data: { status: 'DELETED' }, + }); + + return NextResponse.json({ success: true, id: transfer.id }); + } catch (error) { + console.error('Error deleting transfer:', error); + return NextResponse.json({ error: 'Failed to delete transfer' }, { status: 500 }); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { transfer, unauthenticated } = await findOwnedTransfer(id); + + if (unauthenticated) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!transfer) { + return NextResponse.json({ error: 'Transfer not found' }, { status: 404 }); + } + + // file.size is a BigInt column; seed the accumulator as BigInt and convert + // once at the end, since JSON.stringify cannot serialize BigInt. + const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0)); + const totalDownloads = transfer.files.reduce((sum, file) => sum + file.downloads.length, 0); + + return NextResponse.json({ + transfer: { + id: transfer.id, + senderEmail: transfer.senderEmail, + recipientEmail: transfer.recipientEmail, + message: transfer.message, + filenames: transfer.filenames ? JSON.parse(transfer.filenames) : [], + createdAt: transfer.createdAt.toISOString(), + expiresAt: transfer.expiresAt.toISOString(), + status: transfer.status, + totalFiles: transfer.totalFiles, + totalSize: Number(totalSize), + totalDownloads, + files: transfer.files.map((file) => ({ + id: file.id, + name: file.name, + size: Number(file.size), + downloadCount: file.downloads.length, + })), + }, + }); + } catch (error) { + console.error('Error fetching transfer:', error); + return NextResponse.json({ error: 'Failed to fetch transfer' }, { status: 500 }); + } +} diff --git a/src/app/api/transfers/route.ts b/src/app/api/transfers/route.ts new file mode 100644 index 0000000..4c628a7 --- /dev/null +++ b/src/app/api/transfers/route.ts @@ -0,0 +1,50 @@ +// app/api/transfers/route.ts +import { NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const email = session.user.email; + + const transfers = await prisma.transfer.findMany({ + where: { + senderEmail: email, + }, + include: { + files: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // `size` is a BigInt column and JSON.stringify throws on BigInt, so every + // value crossing the response boundary is converted to Number here. + const formattedTransfers = transfers.map((t) => ({ + id: t.id, + senderEmail: t.senderEmail, + recipientEmail: t.recipientEmail, + message: t.message, + createdAt: t.createdAt.toISOString(), + expiresAt: t.expiresAt.toISOString(), + status: t.status, + totalFiles: t.totalFiles ?? t.files.length, + totalSize: Number(t.files.reduce((sum, f) => sum + f.size, BigInt(0))), + downloadCount: 0, // optionally calculate from FileDownload + downloadUrl: t.downloadUrl, + files: t.files.map((f) => ({ + id: f.id, + name: f.name, + size: Number(f.size), + })), + })); + + return NextResponse.json({ transfers: formattedTransfers }); +} diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts new file mode 100644 index 0000000..d738b22 --- /dev/null +++ b/src/app/api/usage/route.ts @@ -0,0 +1,65 @@ +// app/api/usage/route.ts +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { NextResponse } from 'next/server'; + +const PLAN_LIMITS_MB = { + free: 20480, // 10 GB + rookie: 30720, // 30 GB + pro: 1024 * 1024, // 1 TB +}; + +export async function GET() { + const session = await getServerSession(authOptions); + + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + plan: true, + }, + }); + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + const plan = (user.plan || 'free') as keyof typeof PLAN_LIMITS_MB; + const maxMonthlyMB = PLAN_LIMITS_MB[plan]; + + const monthStart = new Date(); + monthStart.setDate(1); + monthStart.setHours(0, 0, 0, 0); + + const usedBytes = await prisma.file.aggregate({ + _sum: { + size: true, + }, + where: { + transfer: { + is: { + userId: user.id, + createdAt: { + gte: monthStart, + }, + }, + }, + }, + }); + + const usedMB = Math.ceil(Number(usedBytes._sum.size || 0) / 1024 / 1024); + const remainingMB = Math.max(maxMonthlyMB - usedMB, 0); + console.log('Used bytes:', usedBytes); + + return NextResponse.json({ + plan, + usedMB, + remainingMB, + maxMonthlyMB, + }); +} diff --git a/src/app/api/verify/route.ts b/src/app/api/verify/route.ts new file mode 100644 index 0000000..4f1a516 --- /dev/null +++ b/src/app/api/verify/route.ts @@ -0,0 +1,63 @@ +// app/api/verify/route.ts +import { prisma } from '@/lib/prisma' +import { NextResponse } from 'next/server' +import bcrypt from 'bcrypt' +import { TransferStatus } from '@prisma/client' + +export async function POST(req: Request) { + const { id, password } = await req.json() + + if (!id || !password) { + return NextResponse.json({ success: false, message: 'Missing ID or password' }, { status: 400 }) + } + + const transfer = await prisma.transfer.findUnique({ + where: { downloadUrl: id }, + include: { files: true }, + }) + + if (!transfer) { + return NextResponse.json({ success: false, message: 'Transfer not found' }, { status: 404 }) + } + + if (transfer.status !== TransferStatus.ACTIVE || transfer.expiresAt <= new Date()) { + return NextResponse.json( + { success: false, message: 'This transfer is no longer available' }, + { status: 410 } + ) + } + + if (!transfer.passwordHash) { + return NextResponse.json( + { success: false, message: 'This transfer is not downloadable' }, + { status: 409 } + ) + } + + const isValid = await bcrypt.compare(password, transfer.passwordHash) + if (!isValid) { + return NextResponse.json({ success: false, message: 'Incorrect password' }, { status: 401 }) + } + + // Explicit allow-list rather than spreading the row: `files` carries absolute + // server paths, and the row carries passwordHash and the owning userId. + const totalSize = transfer.files.reduce((sum, file) => sum + file.size, BigInt(0)) + + return NextResponse.json({ + success: true, + transfer: { + senderEmail: transfer.senderEmail, + message: transfer.message, + createdAt: transfer.createdAt.toISOString(), + expiresAt: transfer.expiresAt.toISOString(), + totalFiles: transfer.totalFiles, + totalSize: Number(totalSize), + filenames: transfer.filenames ? JSON.parse(transfer.filenames) : [], + files: transfer.files.map((file) => ({ + id: file.id, + name: file.name, + size: Number(file.size), + })), + }, + }) +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx new file mode 100644 index 0000000..f268653 --- /dev/null +++ b/src/app/auth/signin/page.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useState } from 'react'; +import { signIn, getSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { + Upload, + Mail, + Lock, + Eye, + EyeOff, + Github, + Chrome, + ArrowLeft, + Loader2 +} from 'lucide-react'; +import { toast } from 'sonner'; + +export default function SignInPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadingProvider, setLoadingProvider] = useState(null); + const router = useRouter(); + + const handleEmailSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) { + toast.error('Please fill in all fields'); + return; + } + + setIsLoading(true); + try { + const result = await signIn('credentials', { + email, + password, + redirect: false, + }); + + if (result?.error) { + toast.error('Invalid credentials'); + } else { + toast.success('Welcome back!'); + router.push('/'); + } + } catch (error) { + toast.error('Something went wrong'); + } finally { + setIsLoading(false); + } + }; + + const handleProviderSignIn = async (provider: string) => { + setLoadingProvider(provider); + try { + await signIn(provider, { callbackUrl: '/send' }); + } catch (error) { + toast.error(`Failed to sign in with ${provider}`); + setLoadingProvider(null); + } + }; + + return ( +
+
+
+ + + Back to Transfer Tribe + + +
+
+ +
+ + Transfer Tribe + +
+ +

+ Welcome back +

+

+ Sign in to your account to continue sharing files securely +

+
+ + + + + Sign in to your account + + + + {/* Social Sign In */} +
+ + + +
+ +
+
+ +
+
+ + Or continue with email + +
+
+ + {/* Email Sign In */} +
+
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10 pr-10" + required + /> + +
+
+ +
+ + Forgot password? + +
+ + +
+ +
+

+ Don't have an account?{' '} + + Sign up + +

+
+
+
+ +
+

+ By signing in, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..db7ea00 --- /dev/null +++ b/src/app/auth/signup/page.tsx @@ -0,0 +1,317 @@ +'use client'; + +import { useState } from 'react'; +import { signIn } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Upload, + Mail, + Lock, + Eye, + EyeOff, + Github, + Chrome, + ArrowLeft, + Loader2, + User +} from 'lucide-react'; +import { toast } from 'sonner'; + +export default function SignUpPage() { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [acceptTerms, setAcceptTerms] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [loadingProvider, setLoadingProvider] = useState(null); + const router = useRouter(); + + const handleEmailSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!name || !email || !password || !confirmPassword) { + toast.error('Please fill in all fields'); + return; + } + + if (password !== confirmPassword) { + toast.error('Passwords do not match'); + return; + } + + if (password.length < 8) { + toast.error('Password must be at least 8 characters'); + return; + } + + if (!acceptTerms) { + toast.error('Please accept the terms and conditions'); + return; + } + + setIsLoading(true); + try { + // In a real app, you would create the user account first + // For demo purposes, we'll simulate account creation and then sign in + + // Simulate API call delay + await new Promise(resolve => setTimeout(resolve, 1000)); + + const result = await signIn('credentials', { + email, + password, + redirect: false, + }); + + if (result?.error) { + toast.error('Failed to create account'); + } else { + toast.success('Account created successfully!'); + router.push('/'); + } + } catch (error) { + toast.error('Something went wrong'); + } finally { + setIsLoading(false); + } + }; + + const handleProviderSignIn = async (provider: string) => { + setLoadingProvider(provider); + try { + await signIn(provider, { callbackUrl: '/' }); + } catch (error) { + toast.error(`Failed to sign up with ${provider}`); + setLoadingProvider(null); + } + }; + + return ( +
+
+
+ + + Back to Transfer Tribe + + +
+
+ +
+ + Transfer Tribe + +
+ +

+ Create your account +

+

+ Join thousands of users sharing files securely +

+
+ + + + + Sign up for Transfer Tribe + + + + {/* Social Sign Up */} +
+ + + +
+ +
+
+ +
+
+ + Or continue with email + +
+
+ + {/* Email Sign Up */} +
+
+ +
+ + setName(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10 pr-10" + required + /> + +
+

+ Must be at least 8 characters +

+
+ +
+ +
+ + setConfirmPassword(e.target.value)} + className="pl-10 pr-10" + required + /> + +
+
+ +
+ setAcceptTerms(checked as boolean)} + /> + +
+ + +
+ +
+

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

+
+
+
+ +
+

+ By creating an account, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/download/[id]/page.tsx b/src/app/download/[id]/page.tsx new file mode 100644 index 0000000..373a760 --- /dev/null +++ b/src/app/download/[id]/page.tsx @@ -0,0 +1,470 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams } from 'next/navigation'; +import { decryptBlob } from '@/lib/encryption'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Separator } from '@/components/ui/separator'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Download, + File, + FileText, + Image, + Music, + Video, + Archive, + Clock, + Shield, + User, + MessageSquare, + CheckCircle, + Lock, + Eye, + EyeOff, + AlertCircle, + Mail, + HardDrive, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { format } from 'date-fns'; +import { formatFileSize, formatTimeRemaining } from '@/lib/utils'; + +export default function DownloadPage() { + const { id } = useParams() as { id: string }; + + const [transfer, setTransfer] = useState(null); + const [password, setPassword] = useState(''); + const [status, setStatus] = useState(''); + const [downloading, setDownloading] = useState(false); + + const [showPassword, setShowPassword] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [passwordError, setPasswordError] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + const [downloadingFiles, setDownloadingFiles] = useState>( + new Set() + ); + const [downloadingAll, setDownloadingAll] = useState(false); + const [downloadedFiles, setDownloadedFiles] = useState>( + new Set() + ); + + useEffect(() => { + if (!id) return; + + const loadTransfer = async () => { + try { + const res = await fetch(`/api/download?id=${encodeURIComponent(id)}`); + + const data = await res.json(); + + if (data.success) { + setTransfer(data.transfer); + } else { + setStatus(data.message || 'Download not found or expired.'); + } + } catch (err) { + console.error(err); + setStatus('Failed to load transfer.'); + } + }; + + loadTransfer(); + }, [id]); + + const handleDecrypt = async () => { + try { + setDownloading(true); + setDownloadingAll(true); + setStatus('Downloading and decrypting file...'); + + // POST so the password stays out of the URL (and therefore out of access + // logs and Referer headers). + const res = await fetch(`/api/file/${encodeURIComponent(id)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }), + }); + + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || 'File not found or invalid password.'); + } + + const decryptedBlob = await res.blob(); + const url = URL.createObjectURL(decryptedBlob); + const a = document.createElement('a'); + a.href = url; + a.download = transfer.files?.[0]?.name?.replace(/\.enc$/, '') || 'file'; + a.click(); + URL.revokeObjectURL(url); + + setStatus('Download complete!'); + } catch (err: any) { + console.error(err); + setStatus('Download failed: ' + err.message); + } finally { + setDownloading(false); + setDownloadingAll(false); + setDownloadedFiles(new Set(transfer.files.map((f: any) => f.id))); + } + }; + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsVerifying(true); + setPasswordError(''); + setStatus(''); + + try { + const res = await fetch(`/api/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, password }), + }); + + const data = await res.json(); + + if (data.success) { + setTransfer(data.transfer); + setIsAuthenticated(true); + } else { + setPasswordError( + data.message || 'Incorrect password. Please try again.' + ); + } + } catch (err) { + console.error(err); + setPasswordError('Something went wrong. Try again.'); + } finally { + setIsVerifying(false); + } + }; + + const isFileDownloading = (fileId: string) => downloadingFiles.has(fileId); + const isFileDownloaded = (fileId: string) => downloadedFiles.has(fileId); + + if (!transfer) { + return ( +
+
+
+ +
+

+ Loading transfer... +

+
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+ {/* Modern Background */} +
+
+
+ + {/* Animated Background Elements */} +
+
+
+
+
+ {/* Transfer Info Card */} + + +
+ +
+ + File Transfer + + + You have received files from {transfer.senderEmail} + +
+ + {/* Sender Info */} +
+
+ +
+
+ {transfer.senderEmail} +
+
+
+
+ + + + {/* Transfer Stats */} +
+
+ +
+
Files
+
+ {transfer.fileCount} +
+
+
+
+ +
+
Total Size
+
+ {formatFileSize(transfer.totalSize)} +
+
+
+
+ +
+ +
+ Expires: + + {formatTimeRemaining(new Date(transfer.expiresAt))} + +
+
+
+
+ + +
+ +
+ + Protected Transfer + + + Enter the password to access your files + +
+ +
+
+ +
+ setPassword(e.target.value)} + placeholder="Enter transfer password" + className="bg-gray-800 border-gray-700 text-white placeholder-gray-500 pr-12 h-12 focus:border-blue-500 focus:ring-blue-500" + required + /> + +
+ {passwordError && ( +
+ + {passwordError} +
+ )} +
+ + +
+ +
+
+ + Secure encrypted transfer +
+
+
+
+
+
+
+ ); + } + + return ( +
+ {/* Modern Background */} +
+
+
+ + {/* Animated Background Elements */} +
+
+ +
+ {/* Header */} +
+
+
+ +
+ FileTransfer +
+

+ Download Your Files +

+

+ Your files are ready for download +

+
+ + {/* Transfer Info Card */} + + +
+
+ + Transfer from {transfer.senderName} + + +
+ + {transfer.senderEmail} +
+
+
+ + + Secure Transfer + +
+
+ + {transfer.message && ( +
+
+ + + Message: + +
+

+ {transfer.message} +

+
+ )} + +
+
+ + {transfer.files.length} files +
+
+ + {formatFileSize(transfer.totalSize)} total +
+
+ + Expires {transfer.expiresAt} +
+
+
+
+ + {/* Download All Button */} +
+ +
+ + + + {/* Files List */} +
+

+ Files in this transfer +

+ {transfer.filenames?.map((name: string, index: number) => ( + + +

{name}

+
+
+ ))} +
+ + {/* Footer */} +
+
+ + All downloads are secure and encrypted +
+

+ This transfer will expire on {transfer.expiresAt}. Download your + files before this date. +

+
+
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index a2dc41e..169b681 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,26 +1,122 @@ @import "tailwindcss"; +@import "tw-animate-css"; -:root { - --background: #ffffff; - --foreground: #171717; -} +@custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +:root { + --radius: 0.625rem; + --card: oklch(1 0 0); + --card-foreground: oklch(0.147 0.004 49.25); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.147 0.004 49.25); + --primary: oklch(0.216 0.006 56.043); + --primary-foreground: oklch(0.985 0.001 106.423); + --secondary: oklch(0.97 0.001 106.424); + --secondary-foreground: oklch(0.216 0.006 56.043); + --muted: oklch(0.97 0.001 106.424); + --muted-foreground: oklch(0.553 0.013 58.071); + --accent: oklch(0.97 0.001 106.424); + --accent-foreground: oklch(0.216 0.006 56.043); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.923 0.003 48.717); + --input: oklch(0.923 0.003 48.717); + --ring: oklch(0.709 0.01 56.259); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0.001 106.423); + --sidebar-foreground: oklch(0.147 0.004 49.25); + --sidebar-primary: oklch(0.216 0.006 56.043); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.97 0.001 106.424); + --sidebar-accent-foreground: oklch(0.216 0.006 56.043); + --sidebar-border: oklch(0.923 0.003 48.717); + --sidebar-ring: oklch(0.709 0.01 56.259); + --background: oklch(1 0 0); + --foreground: oklch(0.147 0.004 49.25); +} + +.dark { + --background: oklch(0.147 0.004 49.25); + --foreground: oklch(0.985 0.001 106.423); + --card: oklch(0.216 0.006 56.043); + --card-foreground: oklch(0.985 0.001 106.423); + --popover: oklch(0.216 0.006 56.043); + --popover-foreground: oklch(0.985 0.001 106.423); + --primary: oklch(0.923 0.003 48.717); + --primary-foreground: oklch(0.216 0.006 56.043); + --secondary: oklch(0.268 0.007 34.298); + --secondary-foreground: oklch(0.985 0.001 106.423); + --muted: oklch(0.268 0.007 34.298); + --muted-foreground: oklch(0.709 0.01 56.259); + --accent: oklch(0.268 0.007 34.298); + --accent-foreground: oklch(0.985 0.001 106.423); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.553 0.013 58.071); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.216 0.006 56.043); + --sidebar-foreground: oklch(0.985 0.001 106.423); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0.001 106.423); + --sidebar-accent: oklch(0.268 0.007 34.298); + --sidebar-accent-foreground: oklch(0.985 0.001 106.423); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.553 0.013 58.071); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; } } - -body { - background: var(--background); - color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..4cdffd0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,34 +1,37 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; +import './globals.css'; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/sonner'; +import { AuthProvider } from '@/components/auth-provider'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: 'Transfer Tribe - Secure File Sharing', + description: 'Fast, secure, and reliable file transfer service powered by Google Cloud Storage', }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - - {children} + + + + + {children} + + + ); -} +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index e68abe6..4e5faa7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,278 @@ -import Image from "next/image"; +'use client'; + +import { useSession } from 'next-auth/react'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { Header } from '@/components/header'; +import { HeroSection } from '@/components/hero-section'; +import { Stats } from '@/components/stats'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + ArrowRight, + Shield, + Zap, + Globe, + Mail, + Lock, + Clock, + Users, + Star, + CheckCircle +} from 'lucide-react'; +import Link from 'next/link'; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+ const { data: session, status } = useSession(); + const router = useRouter(); -
- - Vercel logomark - Deploy now - - - Read our docs - + useEffect(() => { + // Redirect logged-in users to dashboard + if (session) { + router.push('/send'); + } + }, [session, router]); + + if (status === 'loading') { + return ( +
+
+
+ +
+

Loading...

+
+
+ ); + } + + if (session) { + return null; // Will redirect to dashboard + } + + return ( +
+
+ +
+ + +
+ + + {/* Features Section */} +
+
+

+ Why Choose Transfer Tribe? +

+

+ Built for security, designed for simplicity. Share files with confidence. +

+
+ +
+ + +
+ +
+

+ Enterprise Security +

+

+ Bank-level encryption, password protection, and automatic expiration ensure your files stay secure. +

+
+
+ + + +
+ +
+

+ Lightning Fast +

+

+ Powered by Google Cloud Storage for maximum speed and reliability. Upload and share in seconds. +

+
+
+ + + +
+ +
+

+ Email Integration +

+

+ Recipients get secure download links directly in their inbox. No accounts required to download. +

+
+
+
+
+ + {/* How It Works */} +
+
+

+ How It Works +

+

+ Share files in three simple steps +

+
+ +
+
+
+ 1 +
+

+ Upload Files +

+

+ Drag and drop your files or click to select. Files up to 2GB are supported on the free plan. +

+
+ +
+
+ 2 +
+

+ Add Recipients +

+

+ Enter recipient email addresses and add an optional personal message. +

+
+ +
+
+ 3 +
+

+ Send Securely +

+

+ Recipients receive an email with a secure download link and password. +

+
+
+
+ + {/* Testimonials */} +
+
+

+ Trusted by Thousands +

+

+ See what our users are saying about Transfer Tribe +

+
+ +
+ + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+

+ "Transfer Tribe has revolutionized how we share large design files with clients. The security features give us peace of mind." +

+
+
+ S +
+
+

Sarah Chen

+

Creative Director

+
+
+
+
+ + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+

+ "Simple, fast, and secure. Exactly what we needed for sharing confidential documents with our legal team." +

+
+
+ M +
+
+

Michael Rodriguez

+

Business Owner

+
+
+
+
+ + + +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+

+ "The email integration is brilliant. Our clients love how easy it is to receive and download files." +

+
+
+ A +
+
+

Alex Thompson

+

Freelancer

+
+
+
+
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to Start Sharing? +

+

+ Join thousands of users who trust Transfer Tribe for secure file sharing. + Get started with our free plan today. +

+
+ + + + + + +
+
+
-
); -} +} \ No newline at end of file diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx new file mode 100644 index 0000000..0240984 --- /dev/null +++ b/src/app/pricing/page.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/header'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Check, + X, + Zap, + Crown, + Gift, + Upload, + Clock, + Shield, + Users, + Mail, + Settings, + Infinity +} from 'lucide-react'; + +interface PricingTier { + name: string; + price: string; + period: string; + description: string; + icon: React.ComponentType; + popular?: boolean; + features: { + name: string; + included: boolean; + value?: string; + }[]; + cta: string; + color: string; +} + +export default function PricingPage() { + const [isAnnual, setIsAnnual] = useState(false); + + const tiers: PricingTier[] = [ + { + name: 'Free', + price: '$0', + period: 'forever', + description: 'Perfect for personal use and trying out Transfer Tribe', + icon: Gift, + color: 'from-slate-500 to-slate-600', + cta: 'Get Started Free', + features: [ + { name: 'File size limit', included: true, value: '2GB per transfer' }, + { name: 'Monthly transfers', included: true, value: '10 transfers' }, + { name: 'Transfer expiration', included: true, value: '48 hours' }, + { name: 'Password protection', included: true }, + { name: 'Email notifications', included: true }, + { name: 'Basic support', included: true }, + { name: 'Custom branding', included: false }, + { name: 'Extended expiration', included: false }, + { name: 'Priority support', included: false }, + { name: 'Advanced analytics', included: false }, + ], + }, + { + name: 'Rookie', + price: isAnnual ? '$8' : '$10', + period: isAnnual ? 'per month (billed annually)' : 'per month', + description: 'Ideal for professionals and small teams who need more flexibility', + icon: Zap, + popular: true, + color: 'from-blue-500 to-indigo-600', + cta: 'Start Free Trial', + features: [ + { name: 'File size limit', included: true, value: '10GB per transfer' }, + { name: 'Monthly transfers', included: true, value: '100 transfers' }, + { name: 'Transfer expiration', included: true, value: 'Up to 7 days' }, + { name: 'Password protection', included: true }, + { name: 'Email notifications', included: true }, + { name: 'Priority support', included: true }, + { name: 'Download tracking', included: true }, + { name: 'Transfer history', included: true, value: '6 months' }, + { name: 'Custom branding', included: false }, + { name: 'Advanced analytics', included: false }, + ], + }, + { + name: 'Pro', + price: isAnnual ? '$20' : '$25', + period: isAnnual ? 'per month (billed annually)' : 'per month', + description: 'For businesses and power users who need maximum control', + icon: Crown, + color: 'from-purple-500 to-pink-600', + cta: 'Start Free Trial', + features: [ + { name: 'File size limit', included: true, value: 'Unlimited' }, + { name: 'Monthly transfers', included: true, value: 'Unlimited' }, + { name: 'Transfer expiration', included: true, value: 'Custom (1-30 days)' }, + { name: 'Password protection', included: true }, + { name: 'Email notifications', included: true }, + { name: 'Priority support', included: true }, + { name: 'Download tracking', included: true }, + { name: 'Transfer history', included: true, value: 'Unlimited' }, + { name: 'Custom branding', included: true }, + { name: 'Advanced analytics', included: true }, + ], + }, + ]; + + return ( +
+
+ +
+
+

+ Choose Your Plan +

+

+ Start free and upgrade as you grow. All plans include our core security features and 99.9% uptime guarantee. +

+ +
+ + Monthly + + + + Annual + + {isAnnual && ( + + Save 20% + + )} +
+
+ +
+ {tiers.map((tier, index) => { + const IconComponent = tier.icon; + return ( + + {tier.popular && ( +
+ + Most Popular + +
+ )} + + +
+ +
+ + {tier.name} + +
+ + {tier.price} + + + {tier.period} + +
+

+ {tier.description} +

+
+ + + + +
+

+ What's included: +

+
    + {tier.features.map((feature, featureIndex) => ( +
  • + {feature.included ? ( + + ) : ( + + )} +
    + + {feature.name} + + {feature.value && ( + + {feature.value} + + )} +
    +
  • + ))} +
+
+
+
+ ); + })} +
+ +
+

+ Why Choose Transfer Tribe? +

+
+
+
+ +
+

Enterprise Security

+

+ Bank-level encryption and security protocols +

+
+ +
+
+ +
+

Lightning Fast

+

+ Powered by Google Cloud for maximum speed +

+
+ +
+
+ +
+

Team Friendly

+

+ Perfect for individuals and teams of any size +

+
+ +
+
+ +
+

Email Integration

+

+ Seamless email delivery and notifications +

+
+
+
+ +
+

+ Need a Custom Solution? +

+

+ For enterprise customers with specific requirements, we offer custom plans with dedicated support, + advanced integrations, and tailored features. +

+ + +
+ +
+

+ All plans include a 14-day free trial. No credit card required. Cancel anytime. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/send/page.tsx b/src/app/send/page.tsx new file mode 100644 index 0000000..233a8a2 --- /dev/null +++ b/src/app/send/page.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useSession } from 'next-auth/react'; +import { useEffect, useState } from 'react'; +import { Header } from '@/components/header'; +import SendPage from '@/components/send-transfer-server-encrypted'; +import { MyTransfers } from '@/components/my-transfers'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { + Upload, + Send, + History, + Files, + TrendingUp, + Download, + Clock, + Shield, +} from 'lucide-react'; + +export default function DashboardPage() { + const [activeTab, setActiveTab] = useState<'send' | 'transfers' | 'files'>( + 'send' + ); + const [userFiles, setUserFiles] = useState([]); + const { data: session, status } = useSession(); + const userName = session?.user?.name || 'friend'; + + const handleFileUpload = (files: any[]) => { + setUserFiles((prev) => [...prev, ...files]); + }; + + const [stats, setStats] = useState({ + transfersSent: 0, + downloads: 0, + totalFilesSent: 0, + activeTransfers: 0, + remainingTransfers: 10, + loading: true, + }); + + const [usage, setUsage] = useState({ + plan: 'free', + usedMB: 0, + remainingMB: 0, + maxMonthlyMB: 20480, + }); + + useEffect(() => { + async function fetchStats() { + try { + const res = await fetch('/api/stats'); + const data = await res.json(); + setStats({ ...data, loading: false }); + } catch (err) { + console.error('Failed to fetch stats:', err); + } + } + + async function fetchUsage() { + try { + const res = await fetch('/api/usage'); + const data = await res.json(); + setUsage(data); + } catch (err) { + console.error('Failed to fetch usage:', err); + } + } + + fetchStats(); + fetchUsage(); + }, []); + + const usagePercent = (usage.usedMB / usage.maxMonthlyMB) * 100; + + return ( +
+
+ +
+
+ {/* Welcome Section */} +
+

+ Welcome back, {userName}! +

+

+ Manage your file transfers and share files securely +

+
+ + {/* Quick Stats */} +
+ + +
+
+ +
+
+

+ {stats.loading ? '...' : stats.transfersSent} +

+

+ Transfers Sent +

+
+
+
+
+ + + +
+
+ +
+
+

+ {stats.loading ? '...' : stats.downloads} +

+

+ Downloads +

+
+
+
+
+ + + +
+
+ +
+
+

+ {stats.loading ? '...' : stats.totalFilesSent} +

+

+ Files Uploaded +

+
+
+
+
+ + + +
+
+ +
+
+

+ {stats.loading ? '...' : stats.activeTransfers} +

+

+ Active Transfers +

+
+
+
+
+
+ + {/* Account Status */} + + +
+
+
+ +
+
+

+ {usage.plan.charAt(0).toUpperCase() + usage.plan.slice(1)}{' '} + Plan +

+

+ {stats.loading + ? 'Loading usage...' + : usage.plan === 'free' + ? '2 GB max transfer size' + : `${usage.maxMonthlyMB / 1024} GB monthly bandwidth`} +

+ {usage.plan === 'free' && ( +

+ {stats.remainingTransfers} transfers remaining +

+ )} + {usage.plan !== 'free' && ( + <> + +

+ {usage.usedMB} MB used of {usage.maxMonthlyMB} MB +

+ + )} +
+
+
+ + Active + + +
+
+
+
+ + {/* Main Dashboard */} +
+
+ +
+ +
+ {activeTab === 'send' && ( + + )} + {activeTab === 'transfers' && } +
+
+
+
+
+ ); +} diff --git a/src/components/auth-provider.tsx b/src/components/auth-provider.tsx new file mode 100644 index 0000000..b4826f8 --- /dev/null +++ b/src/components/auth-provider.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { SessionProvider } from 'next-auth/react'; + +interface AuthProviderProps { + children: React.ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + return {children}; +} \ No newline at end of file diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000..8fc8c0b --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Moon, Sun, Menu, X, Upload } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useSession, signOut } from 'next-auth/react'; +import Link from 'next/link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +export function Header() { + const { theme, setTheme } = useTheme(); + const { data: session, status } = useSession(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const handleSignOut = () => { + signOut({ callbackUrl: '/' }); + }; + + return ( +
+
+
+
+ +
+ +
+ + Transfer Tribe + + +
+ + + +
+ + + {status === 'loading' ? ( +
+ ) : session ? ( + + + + + +
+
+ {session.user?.name && ( +

{session.user.name}

+ )} + {session.user?.email && ( +

+ {session.user.email} +

+ )} +
+
+ + + Dashboard + + + Settings + + + + Sign out + +
+
+ ) : ( + <> + + + + + + + + + )} + + +
+
+ + {isMenuOpen && ( +
+ +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/hero-section.tsx b/src/components/hero-section.tsx new file mode 100644 index 0000000..266d3b7 --- /dev/null +++ b/src/components/hero-section.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/components/ui/button'; +import { ArrowRight, Shield, Zap, Globe, Mail, Lock, Clock } from 'lucide-react'; + +export function HeroSection() { + return ( +
+
+

+ Send Files Securely + Via Email +

+

+ Share files of any size with password protection and 48-hour expiration. + Recipients get secure download links via email - just like WeTransfer. +

+
+ +
+ + +
+ +
+
+
+ +
+

Email Delivery

+

+ Recipients receive secure download links directly in their inbox +

+
+ +
+
+ +
+

Password Protected

+

+ Every transfer is secured with a unique password for safe access +

+
+ +
+
+ +
+

48-Hour Expiry

+

+ Transfers automatically expire after 48 hours for security +

+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/my-transfers.tsx b/src/components/my-transfers.tsx new file mode 100644 index 0000000..0b758a0 --- /dev/null +++ b/src/components/my-transfers.tsx @@ -0,0 +1,316 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useSession } from 'next-auth/react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Search, + Download, + Share2, + Trash2, + File, + Mail, + Clock, + Users, + MoreVertical, + Copy, + RefreshCw, +} from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { toast } from 'sonner'; +import { format } from 'date-fns'; +import { formatFileSize, formatTimeRemaining } from '@/lib/utils'; + +interface Transfer { + id: number; // probably `Int` in Prisma + senderEmail: string; + recipientEmail: string; + message?: string; + files: Array<{ + id: number; + name: string; + size: number; + }>; + createdAt: string; + expiresAt: string; + downloadCount: number; + downloadUrl: string; + status: 'ACTIVE' | 'EXPIRED' | 'DELETED'; + totalFiles: number; + totalSize: number; +} + +export function MyTransfers() { + const { data: session } = useSession(); + const [transfers, setTransfers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const filteredTransfers = transfers.filter( + (transfer) => + transfer.recipientEmail + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + transfer.files.some((file) => + file.name.toLowerCase().includes(searchTerm.toLowerCase()) + ) + ); + + const fetchTransfers = async () => { + if (!session?.user?.email) return; + + setIsLoading(true); + try { + const response = await fetch('/api/transfers'); + if (response.ok) { + const data = await response.json(); + setTransfers(data.transfers); + console.log('Transfers:', data.transfers); + } else { + toast.error('Failed to fetch transfers'); + } + } catch (error) { + toast.error('Failed to fetch transfers'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchTransfers(); + }, [session]); + + const handleResend = async (transferId: number) => { + try { + const response = await fetch(`/api/transfers/${transferId}/resend`, { + method: 'POST', + }); + + if (response.ok) { + toast.success('Transfer resend successful!'); + } else { + toast.error('Failed to resend transfer'); + } + } catch (error) { + toast.error('Failed to resend transfer'); + } + }; + + const handleDelete = async (transferId: number) => { + try { + const response = await fetch(`/api/transfers/${transferId}`, { + method: 'DELETE', + }); + + if (response.ok) { + setTransfers((prev) => prev.filter((t) => t.id !== transferId)); + toast.success('Transfer deleted successfully'); + } else { + toast.error('Failed to delete transfer'); + } + } catch (error) { + toast.error('Failed to delete transfer'); + } + }; + +const copyShareLink = (transferId: number) => { + const transfer = transfers.find((t) => t.id === transferId); + + if (!transfer?.downloadUrl) { + toast.error('Download URL not found.'); + return; + } + + const shareUrl = `${window.location.origin}/download/${transfer.downloadUrl}`; + navigator.clipboard.writeText(shareUrl); + toast.success('Share link copied to clipboard!'); +}; + + + const getStatusColor = (status: string) => { + switch (status) { + case 'ACTIVE': + return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'; + case 'EXPIRED': + return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'; + case 'DELETED': + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'; + } + }; + + return ( +
+
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ +
+
+ + {transfers.length === 0 && !isLoading && ( +
+
+ +
+

+ No transfers found +

+

+ You haven't sent any transfers yet +

+
+ )} + + {filteredTransfers.length > 0 && ( +
+ {filteredTransfers.map((transfer) => ( + + +
+
+
+
+ +
+
+

+ To: {transfer.recipientEmail} +

+

+ {transfer.totalFiles} file + {transfer.totalFiles !== 1 ? 's' : ''} •{' '} + {formatFileSize(transfer.totalSize)} +

+
+
+ +
+
+ + {format(new Date(transfer.createdAt), 'MMM dd, yyyy')} +
+
+ + {transfer.downloadCount} downloads +
+
+ + {formatTimeRemaining(new Date(transfer.expiresAt))} +
+
+ + + {transfer.status.toLowerCase()} + +
+ {transfer.status === 'ACTIVE' && ( + + + + + + copyShareLink(transfer.id)} + > + + Copy Link + + handleResend(transfer.id)} + > + + Resend + + handleDelete(transfer.id)} + className="text-red-600 dark:text-red-400" + > + + Delete + + + + )} +
+ + {transfer.message && ( +
+

+ "{transfer.message}" +

+
+ )} + +
+

+ Files +

+
+ {transfer.files.slice(0, 3).map((file) => ( +
+ {file.name} +
+ ))} + {transfer.files.length > 3 && ( +
+ +{transfer.files.length - 3} more +
+ )} +
+
+
+
+ ))} +
+ )} + + {filteredTransfers.length === 0 && searchTerm && transfers.length > 0 && ( +
+

+ No transfers found matching "{searchTerm}" +

+
+ )} +
+ ); +} diff --git a/src/components/send-transfer-chunked.tsx b/src/components/send-transfer-chunked.tsx new file mode 100644 index 0000000..89d14d2 --- /dev/null +++ b/src/components/send-transfer-chunked.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { useSession } from 'next-auth/react'; +import { useState, useEffect, useRef } from 'react'; +import { encryptBlob } from '@/lib/encryption'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Progress } from '@/components/ui/progress'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent } from '@/components/ui/card'; +import { Upload, File, X, Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatFileSize } from '@/lib/utils'; +import { Loader2 } from 'lucide-react'; + +const PLAN_LIMITS = { + free: { + maxFileSizeMB: 2048, + maxMonthlyTransferSizeMB: 10240, + expiryText: '48 hours after sending', + }, + rookie: { + maxFileSizeMB: Infinity, + maxMonthlyTransferSizeMB: 30720, + expiryText: '7 days after sending', + }, + pro: { + maxFileSizeMB: Infinity, + maxMonthlyTransferSizeMB: 1024 * 1024, + expiryText: '30 days after sending', + }, +}; + +const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks + +export default function SendPage() { + const { data: session } = useSession(); + const [files, setFiles] = useState([]); + const [recipient, setRecipient] = useState(''); + const [password, setPassword] = useState(''); + const [message, setMessage] = useState(''); + const [plan, setPlan] = useState<'free' | 'rookie' | 'pro'>('free'); + const [remainingMB, setRemainingMB] = useState(Infinity); + const [usedMB, setUsedMB] = useState(0); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadStep, setUploadStep] = useState(''); + const [uploadMessage, setUploadMessage] = useState(''); + const [currentChunk, setCurrentChunk] = useState(0); + const [totalChunks, setTotalChunks] = useState(0); + const dropRef = useRef(null); + const totalSize = files.reduce((acc, file) => acc + file.size, 0); + const totalSizeMB = totalSize / 1024 / 1024; + const isOverLimit = + totalSizeMB > PLAN_LIMITS[plan].maxFileSizeMB || totalSizeMB > remainingMB; + + useEffect(() => { + const fetchPlan = async () => { + const res = await fetch('/api/usage'); + if (res.ok) { + const data = await res.json(); + setPlan(data.plan); + setRemainingMB(data.remainingMB); + setUsedMB(data.usedMB); + } + }; + if (session?.user?.email) fetchPlan(); + }, [session]); + + const handleFileAdd = (newFiles: File[]) => { + const unique = newFiles.filter( + (newFile) => + !files.find( + (existing) => + existing.name === newFile.name && existing.size === newFile.size + ) + ); + setFiles((prev) => [...prev, ...unique]); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + if (!e.target.files) return; + handleFileAdd(Array.from(e.target.files)); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer.files) { + handleFileAdd(Array.from(e.dataTransfer.files)); + } + dropRef.current?.classList.remove('border-blue-500'); + }; + + const removeFile = (index: number) => { + setFiles((prev) => prev.filter((_, i) => i !== index)); + }; + + const fileInputRef = useRef(null); + const triggerFileInput = () => fileInputRef.current?.click(); + + const handleSubmit = async () => { + if (!files.length) + return setUploadMessage('Please select at least one file'); + if (!recipient || !password) + return setUploadMessage('All fields are required'); + if (isOverLimit) + return setUploadMessage('File size exceeds your plan limits'); + const senderEmail = session?.user?.email; + if (!senderEmail) + return setUploadMessage('Unable to get sender email. Please log in.'); + + try { + setUploadStep('Preparing files'); + const file = files[0]; // Upload first file for now + + setUploadStep('Encrypting'); + // Encrypt the file using the password + const { encryptedBlob } = await encryptBlob(file, password); + + // Convert blob to Uint8Array once to avoid multiple arrayBuffer() calls + // (which can cause "NotReadableError" on some browsers) + const encryptedBytes = new Uint8Array(await encryptedBlob.arrayBuffer()); + const iv = encryptedBytes.slice(0, 12); + const ivBase64 = btoa(String.fromCharCode(...iv)); + + // Calculate chunks + const CHUNK_SIZE_BYTES = CHUNK_SIZE; + const totalChunksCount = Math.ceil(encryptedBytes.length / CHUNK_SIZE_BYTES); + setTotalChunks(totalChunksCount); + + // Generate upload ID + const uploadId = crypto.randomUUID(); + + // Upload chunks + setUploadStep('Uploading'); + for (let i = 0; i < totalChunksCount; i++) { + setCurrentChunk(i + 1); + + const start = i * CHUNK_SIZE_BYTES; + const end = Math.min(start + CHUNK_SIZE_BYTES, encryptedBytes.length); + const chunkBytes = encryptedBytes.slice(start, end); + const chunkBlob = new Blob([chunkBytes]); + + const formData = new FormData(); + formData.append('chunk', chunkBlob); + formData.append('uploadId', uploadId); + formData.append('chunkIndex', i.toString()); + formData.append('totalChunks', totalChunksCount.toString()); + formData.append('chunkSize', CHUNK_SIZE_BYTES.toString()); + + // Only send metadata on first chunk + if (i === 0) { + formData.append('email', recipient); + formData.append('password', password); + formData.append('sender', senderEmail); + formData.append('originalFilename', file.name); + formData.append('encryptionIv', ivBase64); + const filenames = files.map((f) => f.name); + formData.append('filenames', JSON.stringify(filenames)); + formData.append('message', message); + } + + const uploadRes = await fetch('/api/chunk-upload', { + method: 'POST', + body: formData, + }); + + if (!uploadRes.ok) { + const error = await uploadRes.json(); + throw new Error(error.message || `Chunk ${i} upload failed`); + } + + // Update progress + const chunkProgress = ((i + 1) / totalChunksCount) * 100; + setUploadProgress(Math.round(chunkProgress)); + } + + // Finalize upload + setUploadStep('Finalizing'); + const finalRes = await fetch(`/api/chunk-upload?uploadId=${uploadId}`, { + method: 'PUT', + }); + + if (!finalRes.ok) { + const error = await finalRes.json(); + throw new Error(error.message || 'Failed to finalize upload'); + } + + const result = await finalRes.json(); + + setUploadStep(''); + setUploadMessage('Success! 🎉'); + toast.success('Your transfer has been sent successfully!'); + setFiles([]); + setRecipient(''); + setPassword(''); + setUploadProgress(100); + setTimeout(() => setUploadMessage(''), 3000); + } catch (err: any) { + console.error(err); + setUploadStep(''); + setUploadMessage('Error: ' + err.message); + } + }; + + function UploadStatusToast({ + step, + progress, + message, + currentChunk, + totalChunks, + }: { + step: string; + progress: number; + message: string; + currentChunk: number; + totalChunks: number; + }) { + const showSpinner = ['Encrypting', 'Uploading', 'Finalizing'].includes(step); + const isError = + message.toLowerCase().includes('error') || message.includes('fail'); + + if (!step && !message) return null; + + return ( +
+
+ {showSpinner && ( + + )} +

+ {step}{currentChunk && totalChunks ? ` (${currentChunk}/${totalChunks})` : ''} +

+
+ {!isError && step === 'Uploading' && ( + <> + +

+ ⚠️ Please do not close this page during upload! +

+ + )} +
+ ); + } + + return ( +
+
+ {/* File Upload Section */} +
+
+

+ Upload Files +

+
{ + e.preventDefault(); + dropRef.current?.classList.add('border-blue-500'); + }} + onDragLeave={() => + dropRef.current?.classList.remove('border-blue-500') + } + className="border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all" + > +
+
+ +
+
+

+ Drag & drop files here +

+

+ or click to select files +

+
+ + +
+
+ + {/* Files List */} + {files.length > 0 && ( +
+

+ Selected Files ({files.length}) +

+
+ {files.map((file, i) => ( +
+
+ +
+

+ {file.name} +

+

+ {formatFileSize(file.size)} +

+
+
+ +
+ ))} +
+
+ )} + + {/* Size Info */} +
+

Total Size: {formatFileSize(totalSize)}

+ {remainingMB !== Infinity && ( +

Remaining: {remainingMB.toFixed(2)} MB

+ )} +
+
+
+ + {/* Transfer Details Section */} +
+
+

+ Transfer Details +

+ + +
+ + setRecipient(e.target.value)} + placeholder="recipient@example.com" + className="mt-1" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Create a strong password" + className="mt-1" + /> +
+ +
+ +