Implement comprehensive authentication system (Agent 1)
Add complete user authentication with NextAuth.js supporting: - Email/password credentials authentication - OAuth providers (GitHub, Google) with optional configuration - JWT-based session management with 30-day expiry - Role-based access control (USER, GROWER, FARM_MANAGER, ADMIN) - Permission system with granular access control - Secure password hashing with bcrypt (12 rounds) - Rate limiting on auth endpoints - Password reset flow with secure tokens - Email verification system Files added: - lib/auth/: Core auth library (types, permissions, context, hooks, middleware) - pages/api/auth/: Auth API routes (NextAuth, register, forgot-password, verify-email) - pages/auth/: Auth pages (signin, signup, forgot-password, reset-password, verify-email) - components/auth/: Reusable auth components (LoginForm, RegisterForm, AuthGuard, etc.) Updated _app.tsx to include SessionProvider for auth state management.
This commit is contained in:
parent
705105d9b6
commit
39b6081baa
29 changed files with 3396 additions and 8 deletions
10
.env.example
10
.env.example
|
|
@ -1,5 +1,15 @@
|
|||
# LocalGreenChain Environment Variables
|
||||
|
||||
# NextAuth.js Configuration
|
||||
NEXTAUTH_URL=http://localhost:3001
|
||||
NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32
|
||||
|
||||
# OAuth Providers (optional)
|
||||
GITHUB_ID=your_github_client_id
|
||||
GITHUB_SECRET=your_github_client_secret
|
||||
GOOGLE_CLIENT_ID=your_google_client_id
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
|
||||
# Plants.net API (optional)
|
||||
PLANTS_NET_API_KEY=your_api_key_here
|
||||
|
||||
|
|
|
|||
206
bun.lock
206
bun.lock
|
|
@ -5,13 +5,17 @@
|
|||
"": {
|
||||
"name": "localgreenchain",
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"drupal-jsonapi-params": "^1.2.2",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"next": "^12.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-drupal": "^1.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
|
|
@ -21,6 +25,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
|
|
@ -28,6 +33,7 @@
|
|||
"eslint-config-next": "^12.0.10",
|
||||
"jest": "^29.5.0",
|
||||
"postcss": "^8.4.5",
|
||||
"prisma": "^7.0.0",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^4.5.5",
|
||||
|
|
@ -99,6 +105,8 @@
|
|||
|
||||
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||
|
|
@ -107,6 +115,20 @@
|
|||
|
||||
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
|
||||
|
||||
"@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="],
|
||||
|
||||
"@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="],
|
||||
|
||||
"@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="],
|
||||
|
||||
"@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="],
|
||||
|
||||
"@electric-sql/pglite": ["@electric-sql/pglite@0.3.2", "", {}, "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w=="],
|
||||
|
||||
"@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.6", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.2" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-6RjmgzphIHIBA4NrMGJsjNWK4pu+bCWJlEWlwcxFTVY3WT86dFpKwbZaGWZV6C5Rd7sCk1Z0CI76QEfukLAUXw=="],
|
||||
|
||||
"@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.7", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.2" } }, "sha512-9dAccClqxx4cZB+Ar9B+FZ5WgxDc/Xvl9DPrTWv+dYTf0YNubLzi4wHHRGRGhrJv15XwnyKcGOZAP1VXSneSUg=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
|
@ -115,6 +137,8 @@
|
|||
|
||||
"@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.14.2", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
|
@ -163,6 +187,10 @@
|
|||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.12.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-JwqeCQ1U3fvccttHZq7Tk0m/TMC6WcFAQZdukypW3AzlJYKYTGNVd1ANU2GuhKnv4UQuOFj3oAl0LLG/gxFN1w=="],
|
||||
|
||||
"@next-auth/prisma-adapter": ["@next-auth/prisma-adapter@1.0.7", "", { "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3", "next-auth": "^4" } }, "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw=="],
|
||||
|
||||
"@next/env": ["@next/env@12.3.7", "", {}, "sha512-gCw4sTeHoNr0EUO+Nk9Ll21OzF3PnmM0GlHaKgsY2AWQSqQlMgECvB0YI4k21M9iGy+tQ5RMyXQuoIMpzhtxww=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@12.3.7", "", { "dependencies": { "glob": "7.1.7" } }, "sha512-L3WEJJBd1CUUsuxSEThheAV5Nh6/mzCagwj4LHaYlANBkW8Hmg8Ne8l/Vx/sPyfyE7FjuKyiNYWbSVpXRvrmaw=="],
|
||||
|
|
@ -199,6 +227,30 @@
|
|||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@7.0.0", "", { "dependencies": { "@prisma/client-runtime-utils": "7.0.0" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-FM1NtJezl0zH3CybLxcbJwShJt7xFGSRg+1tGhy3sCB8goUDnxnBR+RC/P35EAW8gjkzx7kgz7bvb0MerY2VSw=="],
|
||||
|
||||
"@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.0.0", "", {}, "sha512-PAiFgMBPrLSaakBwUpML5NevipuKSL3rtNr8pZ8CZ3OBXo0BFcdeGcBIKw/CxJP6H4GNa4+l5bzJPrk8Iq6tDw=="],
|
||||
|
||||
"@prisma/config": ["@prisma/config@7.0.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-TDASB57hyGUwHB0IPCSkoJcXFrJOKA1+R/1o4np4PbS+E0F5MiY5aAyUttO0mSuNQaX7t8VH/GkDemffF1mQzg=="],
|
||||
|
||||
"@prisma/debug": ["@prisma/debug@7.0.0", "", {}, "sha512-SdS3qzfMASHtWimywtkiRcJtrHzacbmMVhElko3DYUZSB0TTLqRYWpddRBJdeGgSLmy1FD55p7uGzIJ+MtfhMg=="],
|
||||
|
||||
"@prisma/dev": ["@prisma/dev@0.13.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.2", "@electric-sql/pglite-socket": "0.0.6", "@electric-sql/pglite-tools": "0.2.7", "@hono/node-server": "1.14.2", "@mrleebo/prisma-ast": "0.12.1", "@prisma/get-platform": "6.8.2", "@prisma/query-plan-executor": "6.18.0", "foreground-child": "3.3.1", "get-port-please": "3.1.2", "hono": "4.7.10", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.21.3", "std-env": "3.9.0", "valibot": "1.1.0", "zeptomatch": "2.0.2" } }, "sha512-QMmF6zFeUF78yv1HYbHvod83AQnl7u6NtKyDhTRZOJup3h1icWs8R7RUVxBJZvM2tBXNAMpLQYYM/8kPlOPegA=="],
|
||||
|
||||
"@prisma/engines": ["@prisma/engines@7.0.0", "", { "dependencies": { "@prisma/debug": "7.0.0", "@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", "@prisma/fetch-engine": "7.0.0", "@prisma/get-platform": "7.0.0" } }, "sha512-ojCL3OFLMCz33UbU9XwH32jwaeM+dWb8cysTuY8eK6ZlMKXJdy6ogrdG3MGB3meKLGdQBmOpUUGJ7eLIaxbrcg=="],
|
||||
|
||||
"@prisma/engines-version": ["@prisma/engines-version@6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", "", {}, "sha512-7bzyN8Gp9GbDFbTDzVUH9nFcgRWvsWmjrGgBJvIC/zEoAuv/lx62gZXgAKfjn/HoPkxz/dS+TtsnduFx8WA+cw=="],
|
||||
|
||||
"@prisma/fetch-engine": ["@prisma/fetch-engine@7.0.0", "", { "dependencies": { "@prisma/debug": "7.0.0", "@prisma/engines-version": "6.20.0-16.next-0c19ccc313cf9911a90d99d2ac2eb0280c76c513", "@prisma/get-platform": "7.0.0" } }, "sha512-qcyWTeWDjVDaDQSrVIymZU1xCYlvmwCzjA395lIuFjUESOH3YQCb8i/hpd4vopfq3fUR4v6+MjjtIGvnmErQgw=="],
|
||||
|
||||
"@prisma/get-platform": ["@prisma/get-platform@6.8.2", "", { "dependencies": { "@prisma/debug": "6.8.2" } }, "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow=="],
|
||||
|
||||
"@prisma/query-plan-executor": ["@prisma/query-plan-executor@6.18.0", "", {}, "sha512-jZ8cfzFgL0jReE1R10gT8JLHtQxjWYLiQ//wHmVYZ2rVkFHoh0DT8IXsxcKcFlfKN7ak7k6j0XMNn2xVNyr5cA=="],
|
||||
|
||||
"@prisma/studio-core-licensed": ["@prisma/studio-core-licensed@0.8.0", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SXCcgFvo/SC6/11kEOaQghJgCWNEWZUvPYKn/gpvMB9HLSG/5M8If7dWZtEQHhchvl8bh9A89Hw6mEKpsXFimA=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.15.0", "", {}, "sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw=="],
|
||||
|
|
@ -209,6 +261,8 @@
|
|||
|
||||
"@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.4.11", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-rEUrBSGIoSFuYxwBYtlUFMlE2CwGhmW+w9355/5oduSw8e5h2+Tj4UrAGNNgP9915++wj5vkQo0UuOBqOAq4nw=="],
|
||||
|
||||
"@tailwindcss/forms": ["@tailwindcss/forms@0.4.1", "", { "dependencies": { "mini-svg-data-uri": "^1.2.3" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, "sha512-gS9xjCmJjUBz/eP12QlENPLnf0tCx68oYE3mri0GMP5jdtVwLbGUNSRpjsp6NzLAZzZy3ueOwrcqB78Ax6Z84A=="],
|
||||
|
|
@ -227,6 +281,8 @@
|
|||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
|
|
@ -315,6 +371,8 @@
|
|||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
|
||||
"axe-core": ["axe-core@4.11.0", "", {}, "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
|
@ -333,6 +391,8 @@
|
|||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.30", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
|
@ -347,6 +407,8 @@
|
|||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
|
@ -365,10 +427,14 @@
|
|||
|
||||
"char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="],
|
||||
|
||||
"chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
|
@ -389,8 +455,14 @@
|
|||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"create-jest": ["create-jest@29.7.0", "", { "dependencies": { "@jest/types": "^29.6.3", "chalk": "^4.0.0", "exit": "^0.1.2", "graceful-fs": "^4.2.9", "jest-config": "^29.7.0", "jest-util": "^29.7.0", "prompts": "^2.0.1" }, "bin": { "create-jest": "bin/create-jest.js" } }, "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
|
@ -415,10 +487,18 @@
|
|||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
|
||||
|
||||
"detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
|
@ -439,16 +519,22 @@
|
|||
|
||||
"domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"drupal-jsonapi-params": ["drupal-jsonapi-params@1.2.3", "", { "dependencies": { "qs": "^6.10.0" } }, "sha512-ZyPXlJkwnNoQ8ERtJiPKY44UzdZDt2RF5NJdh+7UQywx/Q+e7Cu6pHtRs3MJUPEcPUV0dN3jiqCupzBsTGgjmA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.259", "", {}, "sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ=="],
|
||||
|
||||
"emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
|
||||
|
||||
"entities": ["entities@3.0.1", "", {}, "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q=="],
|
||||
|
||||
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
|
||||
|
|
@ -513,6 +599,10 @@
|
|||
|
||||
"expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "^29.7.0", "jest-get-type": "^29.6.3", "jest-matcher-utils": "^29.7.0", "jest-message-util": "^29.7.0", "jest-util": "^29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
|
@ -539,6 +629,8 @@
|
|||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||
|
|
@ -551,6 +643,8 @@
|
|||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
|
@ -561,12 +655,16 @@
|
|||
|
||||
"get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="],
|
||||
|
||||
"get-port-please": ["get-port-please@3.1.2", "", {}, "sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"glob": ["glob@7.1.7", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.0.4", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
|
@ -581,6 +679,8 @@
|
|||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"grammex": ["grammex@3.1.11", "", {}, "sha512-HNwLkgRg9SqTAd1N3Uh/MnKwTBTzwBxTOPbXQ8pb0tpwydjk90k4zRE8JUn9fMUiRwKtXFZ1TWFmms3dZHN+Fg=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
|
||||
|
|
@ -599,6 +699,8 @@
|
|||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.7.10", "", {}, "sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ=="],
|
||||
|
||||
"html-dom-parser": ["html-dom-parser@1.2.0", "", { "dependencies": { "domhandler": "4.3.1", "htmlparser2": "7.2.0" } }, "sha512-2HIpFMvvffsXHFUFjso0M9LqM+1Lm22BF+Df2ba+7QHJXjk63pWChEnI6YG27eaWqUdfnh5/Vy+OXrNTtepRsg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
|
@ -607,8 +709,12 @@
|
|||
|
||||
"htmlparser2": ["htmlparser2@7.2.0", "", { "dependencies": { "domelementtype": "^2.0.1", "domhandler": "^4.2.2", "domutils": "^2.8.0", "entities": "^3.0.1" } }, "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog=="],
|
||||
|
||||
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
|
@ -669,6 +775,8 @@
|
|||
|
||||
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
|
|
@ -759,6 +867,8 @@
|
|||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
|
@ -797,14 +907,20 @@
|
|||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.memoize": ["lodash.memoize@4.1.2", "", {}, "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"lru.min": ["lru.min@1.1.3", "", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="],
|
||||
|
||||
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
|
||||
|
||||
"make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="],
|
||||
|
|
@ -829,8 +945,12 @@
|
|||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
|
@ -839,10 +959,14 @@
|
|||
|
||||
"next": ["next@12.3.7", "", { "dependencies": { "@next/env": "12.3.7", "@swc/helpers": "0.4.11", "caniuse-lite": "^1.0.30001406", "postcss": "8.4.14", "styled-jsx": "5.0.7", "use-sync-external-store": "1.2.0" }, "optionalDependencies": { "@next/swc-android-arm-eabi": "12.3.4", "@next/swc-android-arm64": "12.3.4", "@next/swc-darwin-arm64": "12.3.4", "@next/swc-darwin-x64": "12.3.4", "@next/swc-freebsd-x64": "12.3.4", "@next/swc-linux-arm-gnueabihf": "12.3.4", "@next/swc-linux-arm64-gnu": "12.3.4", "@next/swc-linux-arm64-musl": "12.3.4", "@next/swc-linux-x64-gnu": "12.3.4", "@next/swc-linux-x64-musl": "12.3.4", "@next/swc-win32-arm64-msvc": "12.3.4", "@next/swc-win32-ia32-msvc": "12.3.4", "@next/swc-win32-x64-msvc": "12.3.4" }, "peerDependencies": { "fibers": ">= 3.1.0", "node-sass": "^6.0.0 || ^7.0.0", "react": "^17.0.2 || ^18.0.0-0", "react-dom": "^17.0.2 || ^18.0.0-0", "sass": "^1.3.0" }, "optionalPeers": ["fibers", "node-sass", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-3PDn+u77s5WpbkUrslBP6SKLMeUj9cSx251LOt+yP9fgnqXV/ydny81xQsclz9R6RzCLONMCtwK2RvDdLa/mJQ=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.13", "", { "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.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ=="],
|
||||
|
||||
"next-drupal": ["next-drupal@1.6.0", "", { "dependencies": { "jsona": "^1.9.7", "next": "^12.2.0 || ^13", "node-cache": "^5.1.2", "qs": "^6.10.3", "react": "^17.0.2 || ^18", "react-dom": "^17.0.2 || ^18" } }, "sha512-IRHgcpidXj45jicVl2wEp2WhyaV384rfubxxWopgbmo4YKYvIrg0GtPj3EQNuuX5/EJxyZcULHmmhSXFSidlpg=="],
|
||||
|
||||
"node-cache": ["node-cache@5.1.2", "", { "dependencies": { "clone": "2.x" } }, "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg=="],
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
|
@ -855,6 +979,10 @@
|
|||
|
||||
"nprogress": ["nprogress@0.2.0", "", {}, "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA=="],
|
||||
|
||||
"nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
|
||||
|
||||
"oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
|
@ -873,10 +1001,16 @@
|
|||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||
|
|
@ -901,6 +1035,10 @@
|
|||
|
||||
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
|
@ -911,6 +1049,8 @@
|
|||
|
||||
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
|
@ -927,14 +1067,24 @@
|
|||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||
|
||||
"preact": ["preact@10.27.2", "", {}, "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
|
||||
|
||||
"prisma": ["prisma@7.0.0", "", { "dependencies": { "@prisma/config": "7.0.0", "@prisma/dev": "0.13.0", "@prisma/engines": "7.0.0", "@prisma/studio-core-licensed": "0.8.0", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-VZObZ1pQV/OScarYg68RYUx61GpFLH2mJGf9fUX4XxQxTst/6ZK7nkY86CSZ3zBW6U9lKRTsBrZWVz20X5G/KQ=="],
|
||||
|
||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
|
@ -943,6 +1093,8 @@
|
|||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"react": ["react@17.0.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" } }, "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA=="],
|
||||
|
||||
"react-dom": ["react-dom@17.0.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "scheduler": "^0.20.2" }, "peerDependencies": { "react": "17.0.2" } }, "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA=="],
|
||||
|
|
@ -959,8 +1111,12 @@
|
|||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"remeda": ["remeda@2.21.3", "", { "dependencies": { "type-fest": "^4.39.1" } }, "sha512-XXrZdLA10oEOQhLLzEJEiFFSKi21REGAkHdImIb4rt/XXy8ORGXh5HCcpUOsElfPNDb+X6TA/+wkh+p2KffYmg=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
|
@ -971,6 +1127,8 @@
|
|||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
|
@ -983,10 +1141,14 @@
|
|||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.20.2", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" } }, "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
|
|
@ -1005,7 +1167,7 @@
|
|||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
|
|
@ -1025,8 +1187,12 @@
|
|||
|
||||
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
|
||||
|
||||
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
|
||||
|
||||
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
|
||||
|
||||
"std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="],
|
||||
|
|
@ -1075,6 +1241,8 @@
|
|||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="],
|
||||
|
|
@ -1119,8 +1287,12 @@
|
|||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="],
|
||||
|
||||
"valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="],
|
||||
|
||||
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
|
@ -1153,6 +1325,8 @@
|
|||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zeptomatch": ["zeptomatch@2.0.2", "", { "dependencies": { "grammex": "^3.1.10" } }, "sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
|
@ -1161,6 +1335,14 @@
|
|||
|
||||
"@jest/reporters/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
|
||||
|
||||
"@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.0.0", "", { "dependencies": { "@prisma/debug": "7.0.0" } }, "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg=="],
|
||||
|
||||
"@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.0.0", "", { "dependencies": { "@prisma/debug": "7.0.0" } }, "sha512-zyhzrAa+y/GfyCzTnuk0D9lfkvDzo7IbsNyuhTqhPu/AN0txm0x26HAR4tJLismla/fHf5fBzYwSivYSzkpakg=="],
|
||||
|
||||
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@6.8.2", "", {}, "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg=="],
|
||||
|
||||
"@tailwindcss/typography/postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
|
@ -1169,6 +1351,10 @@
|
|||
|
||||
"babel-plugin-istanbul/istanbul-lib-instrument": ["istanbul-lib-instrument@5.2.1", "", { "dependencies": { "@babel/core": "^7.12.3", "@babel/parser": "^7.14.7", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-coverage": "^3.2.0", "semver": "^6.3.0" } }, "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg=="],
|
||||
|
||||
"c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
|
@ -1187,6 +1373,8 @@
|
|||
|
||||
"eslint-plugin-react/resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||
|
||||
"execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"globals/type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
||||
|
|
@ -1207,14 +1395,24 @@
|
|||
|
||||
"make-dir/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"named-placeholders/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.14", "", { "dependencies": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig=="],
|
||||
|
||||
"next/use-sync-external-store": ["use-sync-external-store@1.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA=="],
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"openid-client/object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"preact-render-to-string/pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
|
||||
|
|
@ -1233,10 +1431,16 @@
|
|||
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"write-file-atomic/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
|
|
|||
128
components/auth/AuthGuard.tsx
Normal file
128
components/auth/AuthGuard.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useAuth } from '@/lib/auth/useAuth'
|
||||
import { UserRole } from '@/lib/auth/types'
|
||||
import { hasRole, hasPermission } from '@/lib/auth/permissions'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: React.ReactNode
|
||||
requiredRole?: UserRole
|
||||
requiredPermission?: string
|
||||
fallback?: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function AuthGuard({
|
||||
children,
|
||||
requiredRole,
|
||||
requiredPermission,
|
||||
fallback,
|
||||
redirectTo = '/auth/signin',
|
||||
}: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
const returnUrl = encodeURIComponent(router.asPath)
|
||||
router.push(`${redirectTo}?callbackUrl=${returnUrl}`)
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router, redirectTo])
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">Redirecting to sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Check role requirement
|
||||
if (requiredRole && user) {
|
||||
if (!hasRole(user.role, requiredRole)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<div className="text-red-500 text-6xl mb-4">403</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You don't have permission to access this page. This page requires{' '}
|
||||
<span className="font-medium">{requiredRole}</span> role or higher.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-green-600 hover:text-green-500 font-medium"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check permission requirement
|
||||
if (requiredPermission && user) {
|
||||
if (!hasPermission(user.role, requiredPermission)) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<div className="text-red-500 text-6xl mb-4">403</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Access Denied</h1>
|
||||
<p className="text-gray-600 mb-4">
|
||||
You don't have the required permission to access this page.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-green-600 hover:text-green-500 font-medium"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Higher-order component version
|
||||
export function withAuthGuard<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
options?: {
|
||||
requiredRole?: UserRole
|
||||
requiredPermission?: string
|
||||
fallback?: React.ReactNode
|
||||
redirectTo?: string
|
||||
}
|
||||
) {
|
||||
return function AuthGuardedComponent(props: P) {
|
||||
return (
|
||||
<AuthGuard {...options}>
|
||||
<Component {...props} />
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthGuard
|
||||
132
components/auth/LoginForm.tsx
Normal file
132
components/auth/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface LoginFormProps {
|
||||
callbackUrl?: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function LoginForm({ callbackUrl = '/', onSuccess, onError }: LoginFormProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
const errorMessage = getErrorMessage(result.error)
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} else if (result?.ok) {
|
||||
onSuccess?.()
|
||||
if (callbackUrl) {
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="login-email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="login-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="login-password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">
|
||||
Forgot password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
CredentialsSignin: 'Invalid email or password',
|
||||
default: 'An error occurred during sign in',
|
||||
}
|
||||
return errorMessages[error] ?? errorMessages.default
|
||||
}
|
||||
|
||||
export default LoginForm
|
||||
142
components/auth/PasswordResetForm.tsx
Normal file
142
components/auth/PasswordResetForm.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useState } from 'react'
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
token: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function PasswordResetForm({ token, onSuccess, onError }: PasswordResetFormProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const validatePassword = (): string | null => {
|
||||
if (password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(password)
|
||||
const hasLowerCase = /[a-z]/.test(password)
|
||||
const hasNumbers = /\d/.test(password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validatePassword()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'An error occurred')
|
||||
onError?.(data.message || 'An error occurred')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg text-center">
|
||||
<h3 className="text-lg font-medium mb-2">Password Reset Successful!</h3>
|
||||
<p>Your password has been reset successfully.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="new-password" className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-700">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-new-password"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default PasswordResetForm
|
||||
195
components/auth/RegisterForm.tsx
Normal file
195
components/auth/RegisterForm.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { useState } from 'react'
|
||||
import { signIn } from 'next-auth/react'
|
||||
|
||||
interface RegisterFormProps {
|
||||
callbackUrl?: string
|
||||
onSuccess?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
export function RegisterForm({ callbackUrl = '/', onSuccess, onError }: RegisterFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.email || !formData.password) {
|
||||
return 'Email and password are required'
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(formData.password)
|
||||
const hasLowerCase = /[a-z]/.test(formData.password)
|
||||
const hasNumbers = /\d/.test(formData.password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
onError?.(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'Registration failed')
|
||||
onError?.(data.message || 'Registration failed')
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess?.()
|
||||
|
||||
// Auto sign in after successful registration
|
||||
const result = await signIn('credentials', {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.ok && callbackUrl) {
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = 'An unexpected error occurred'
|
||||
setError(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="register-name" className="block text-sm font-medium text-gray-700">
|
||||
Full Name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="register-name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="register-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="register-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="register-confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="register-confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default RegisterForm
|
||||
95
components/auth/SocialLoginButtons.tsx
Normal file
95
components/auth/SocialLoginButtons.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { signIn } from 'next-auth/react'
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
callbackUrl?: string
|
||||
providers?: string[]
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, { name: string; icon: JSX.Element; bgColor: string }> = {
|
||||
github: {
|
||||
name: 'GitHub',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-gray-900 hover:bg-gray-800',
|
||||
},
|
||||
google: {
|
||||
name: 'Google',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
bgColor: 'bg-white hover:bg-gray-50 border border-gray-300',
|
||||
},
|
||||
}
|
||||
|
||||
export function SocialLoginButtons({ callbackUrl = '/', providers = ['github', 'google'] }: SocialLoginButtonsProps) {
|
||||
const handleSignIn = (providerId: string) => {
|
||||
signIn(providerId, { callbackUrl })
|
||||
}
|
||||
|
||||
const availableProviders = providers.filter((p) => p in providerConfig)
|
||||
|
||||
if (availableProviders.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{availableProviders.map((providerId) => {
|
||||
const config = providerConfig[providerId]
|
||||
const isGoogle = providerId === 'google'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={providerId}
|
||||
onClick={() => handleSignIn(providerId)}
|
||||
className={`w-full inline-flex justify-center items-center py-2 px-4 rounded-md shadow-sm text-sm font-medium ${
|
||||
config.bgColor
|
||||
} ${isGoogle ? 'text-gray-700' : 'text-white'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
|
||||
>
|
||||
<span className="mr-2">{config.icon}</span>
|
||||
Continue with {config.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SocialDivider() {
|
||||
return (
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialLoginButtons
|
||||
5
components/auth/index.ts
Normal file
5
components/auth/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { LoginForm } from './LoginForm'
|
||||
export { RegisterForm } from './RegisterForm'
|
||||
export { PasswordResetForm } from './PasswordResetForm'
|
||||
export { SocialLoginButtons, SocialDivider } from './SocialLoginButtons'
|
||||
export { AuthGuard, withAuthGuard } from './AuthGuard'
|
||||
120
lib/auth/AuthContext.tsx
Normal file
120
lib/auth/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import * as React from 'react'
|
||||
import { SessionProvider, useSession, signIn, signOut } from 'next-auth/react'
|
||||
import { Session } from 'next-auth'
|
||||
import { AuthUser, UserRole } from './types'
|
||||
import { hasPermission, hasRole } from './permissions'
|
||||
|
||||
interface AuthContextType {
|
||||
user: AuthUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
signIn: typeof signIn
|
||||
signOut: typeof signOut
|
||||
hasPermission: (permission: string) => boolean
|
||||
hasRole: (role: UserRole) => boolean
|
||||
updateSession: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode
|
||||
session?: Session | null
|
||||
}
|
||||
|
||||
function AuthProviderContent({ children }: { children: React.ReactNode }) {
|
||||
const { data: session, status, update } = useSession()
|
||||
const isLoading = status === 'loading'
|
||||
const isAuthenticated = status === 'authenticated'
|
||||
|
||||
const user: AuthUser | null = session?.user
|
||||
? {
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
name: session.user.name,
|
||||
image: session.user.image,
|
||||
role: session.user.role || UserRole.USER,
|
||||
emailVerified: session.user.emailVerified,
|
||||
}
|
||||
: null
|
||||
|
||||
const checkPermission = React.useCallback(
|
||||
(permission: string): boolean => {
|
||||
if (!user) return false
|
||||
return hasPermission(user.role, permission)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const checkRole = React.useCallback(
|
||||
(requiredRole: UserRole): boolean => {
|
||||
if (!user) return false
|
||||
return hasRole(user.role, requiredRole)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const updateSession = React.useCallback(async () => {
|
||||
await update()
|
||||
}, [update])
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
signIn,
|
||||
signOut,
|
||||
hasPermission: checkPermission,
|
||||
hasRole: checkRole,
|
||||
updateSession,
|
||||
}
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function AuthProvider({ children, session }: AuthProviderProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<AuthProviderContent>{children}</AuthProviderContent>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = React.useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Higher-order component for components that require auth
|
||||
export function withAuth<P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
options?: { requiredRole?: UserRole; fallback?: React.ReactNode }
|
||||
) {
|
||||
return function AuthenticatedComponent(props: P) {
|
||||
const { isAuthenticated, isLoading, user } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return options?.fallback || <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (typeof window !== 'undefined') {
|
||||
signIn()
|
||||
}
|
||||
return options?.fallback || <div>Redirecting to sign in...</div>
|
||||
}
|
||||
|
||||
if (options?.requiredRole && user) {
|
||||
if (!hasRole(user.role, options.requiredRole)) {
|
||||
return <div>Access denied. Insufficient permissions.</div>
|
||||
}
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthContext }
|
||||
30
lib/auth/index.ts
Normal file
30
lib/auth/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Types
|
||||
export * from './types'
|
||||
|
||||
// Permissions
|
||||
export * from './permissions'
|
||||
|
||||
// Context and hooks
|
||||
export { AuthProvider, useAuth, withAuth as withAuthComponent, AuthContext } from './AuthContext'
|
||||
export { useAuth as useAuthHook, usePermission, useRole, useRequireAuth } from './useAuth'
|
||||
|
||||
// API middleware
|
||||
export {
|
||||
withAuth,
|
||||
withRole,
|
||||
withPermission,
|
||||
withAnyPermission,
|
||||
withAllPermissions,
|
||||
withRateLimit,
|
||||
checkRateLimit,
|
||||
} from './withAuth'
|
||||
export type { AuthenticatedRequest } from './withAuth'
|
||||
|
||||
// Role-based middleware
|
||||
export {
|
||||
requireRole,
|
||||
requireAdmin,
|
||||
requireFarmManager,
|
||||
requireGrower,
|
||||
requireUser,
|
||||
} from './withRole'
|
||||
107
lib/auth/permissions.ts
Normal file
107
lib/auth/permissions.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { UserRole } from './types'
|
||||
|
||||
export const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||
[UserRole.USER]: 0,
|
||||
[UserRole.GROWER]: 1,
|
||||
[UserRole.FARM_MANAGER]: 2,
|
||||
[UserRole.ADMIN]: 3,
|
||||
}
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<UserRole, string[]> = {
|
||||
[UserRole.USER]: [
|
||||
'plants:read',
|
||||
'plants:register',
|
||||
'transport:read',
|
||||
'demand:read',
|
||||
'demand:signal',
|
||||
'transparency:read',
|
||||
],
|
||||
[UserRole.GROWER]: [
|
||||
'plants:read',
|
||||
'plants:write',
|
||||
'plants:register',
|
||||
'plants:clone',
|
||||
'transport:read',
|
||||
'transport:write',
|
||||
'demand:read',
|
||||
'demand:signal',
|
||||
'demand:supply',
|
||||
'transparency:read',
|
||||
'environment:read',
|
||||
'environment:write',
|
||||
],
|
||||
[UserRole.FARM_MANAGER]: [
|
||||
'plants:read',
|
||||
'plants:write',
|
||||
'plants:register',
|
||||
'plants:clone',
|
||||
'plants:delete',
|
||||
'transport:read',
|
||||
'transport:write',
|
||||
'demand:read',
|
||||
'demand:signal',
|
||||
'demand:supply',
|
||||
'demand:forecast',
|
||||
'transparency:read',
|
||||
'transparency:write',
|
||||
'environment:read',
|
||||
'environment:write',
|
||||
'vertical-farm:read',
|
||||
'vertical-farm:write',
|
||||
'vertical-farm:manage',
|
||||
],
|
||||
[UserRole.ADMIN]: [
|
||||
'plants:*',
|
||||
'transport:*',
|
||||
'demand:*',
|
||||
'transparency:*',
|
||||
'environment:*',
|
||||
'vertical-farm:*',
|
||||
'users:*',
|
||||
'system:*',
|
||||
],
|
||||
}
|
||||
|
||||
export function hasPermission(role: UserRole, permission: string): boolean {
|
||||
const permissions = ROLE_PERMISSIONS[role] || []
|
||||
|
||||
// Check for exact match
|
||||
if (permissions.includes(permission)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for wildcard permissions (e.g., 'plants:*' matches 'plants:read')
|
||||
const [resource] = permission.split(':')
|
||||
const wildcardPermission = `${resource}:*`
|
||||
if (permissions.includes(wildcardPermission)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function hasRole(userRole: UserRole, requiredRole: UserRole): boolean {
|
||||
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
|
||||
}
|
||||
|
||||
export function hasAnyPermission(role: UserRole, permissions: string[]): boolean {
|
||||
return permissions.some(permission => hasPermission(role, permission))
|
||||
}
|
||||
|
||||
export function hasAllPermissions(role: UserRole, permissions: string[]): boolean {
|
||||
return permissions.every(permission => hasPermission(role, permission))
|
||||
}
|
||||
|
||||
export function getRoleLabel(role: UserRole): string {
|
||||
const labels: Record<UserRole, string> = {
|
||||
[UserRole.USER]: 'User',
|
||||
[UserRole.GROWER]: 'Grower',
|
||||
[UserRole.FARM_MANAGER]: 'Farm Manager',
|
||||
[UserRole.ADMIN]: 'Administrator',
|
||||
}
|
||||
return labels[role] || 'Unknown'
|
||||
}
|
||||
|
||||
export function getAvailableRoles(): UserRole[] {
|
||||
return Object.values(UserRole)
|
||||
}
|
||||
76
lib/auth/types.ts
Normal file
76
lib/auth/types.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { DefaultSession, DefaultUser } from 'next-auth'
|
||||
import { JWT, DefaultJWT } from 'next-auth/jwt'
|
||||
|
||||
export enum UserRole {
|
||||
USER = 'USER',
|
||||
GROWER = 'GROWER',
|
||||
FARM_MANAGER = 'FARM_MANAGER',
|
||||
ADMIN = 'ADMIN',
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role: UserRole
|
||||
emailVerified?: Date | null
|
||||
}
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session extends DefaultSession {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
interface User extends DefaultUser {
|
||||
role: UserRole
|
||||
emailVerified?: Date | null
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'next-auth/jwt' {
|
||||
interface JWT extends DefaultJWT {
|
||||
id: string
|
||||
role: UserRole
|
||||
emailVerified?: Date | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
email: string
|
||||
password: string
|
||||
name?: string
|
||||
role?: UserRole
|
||||
}
|
||||
|
||||
export interface LoginInput {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface ForgotPasswordInput {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface ResetPasswordInput {
|
||||
token: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface VerifyEmailInput {
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
user?: AuthUser
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string
|
||||
email: string
|
||||
type: 'email_verification' | 'password_reset'
|
||||
expiresAt: number
|
||||
}
|
||||
157
lib/auth/useAuth.ts
Normal file
157
lib/auth/useAuth.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useSession, signIn, signOut } from 'next-auth/react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { AuthUser, UserRole } from './types'
|
||||
import { hasPermission, hasRole, hasAnyPermission, hasAllPermissions } from './permissions'
|
||||
|
||||
interface UseAuthReturn {
|
||||
// User state
|
||||
user: AuthUser | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
|
||||
// Auth actions
|
||||
login: (provider?: string, options?: { callbackUrl?: string }) => Promise<void>
|
||||
logout: (options?: { callbackUrl?: string }) => Promise<void>
|
||||
loginWithCredentials: (email: string, password: string, callbackUrl?: string) => Promise<void>
|
||||
|
||||
// Permission checks
|
||||
can: (permission: string) => boolean
|
||||
canAny: (permissions: string[]) => boolean
|
||||
canAll: (permissions: string[]) => boolean
|
||||
is: (role: UserRole) => boolean
|
||||
isAtLeast: (role: UserRole) => boolean
|
||||
|
||||
// Session management
|
||||
refreshSession: () => Promise<void>
|
||||
}
|
||||
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const { data: session, status, update } = useSession()
|
||||
const isLoading = status === 'loading'
|
||||
const isAuthenticated = status === 'authenticated'
|
||||
|
||||
const user: AuthUser | null = useMemo(() => {
|
||||
if (!session?.user) return null
|
||||
return {
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
name: session.user.name,
|
||||
image: session.user.image,
|
||||
role: session.user.role || UserRole.USER,
|
||||
emailVerified: session.user.emailVerified,
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const login = useCallback(
|
||||
async (provider?: string, options?: { callbackUrl?: string }) => {
|
||||
await signIn(provider, { callbackUrl: options?.callbackUrl || '/' })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const logout = useCallback(async (options?: { callbackUrl?: string }) => {
|
||||
await signOut({ callbackUrl: options?.callbackUrl || '/' })
|
||||
}, [])
|
||||
|
||||
const loginWithCredentials = useCallback(
|
||||
async (email: string, password: string, callbackUrl?: string) => {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
if (callbackUrl) {
|
||||
window.location.href = callbackUrl
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const can = useCallback(
|
||||
(permission: string): boolean => {
|
||||
if (!user) return false
|
||||
return hasPermission(user.role, permission)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const canAny = useCallback(
|
||||
(permissions: string[]): boolean => {
|
||||
if (!user) return false
|
||||
return hasAnyPermission(user.role, permissions)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const canAll = useCallback(
|
||||
(permissions: string[]): boolean => {
|
||||
if (!user) return false
|
||||
return hasAllPermissions(user.role, permissions)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const is = useCallback(
|
||||
(role: UserRole): boolean => {
|
||||
if (!user) return false
|
||||
return user.role === role
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const isAtLeast = useCallback(
|
||||
(role: UserRole): boolean => {
|
||||
if (!user) return false
|
||||
return hasRole(user.role, role)
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
await update()
|
||||
}, [update])
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
loginWithCredentials,
|
||||
can,
|
||||
canAny,
|
||||
canAll,
|
||||
is,
|
||||
isAtLeast,
|
||||
refreshSession,
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for checking a specific permission
|
||||
export function usePermission(permission: string): boolean {
|
||||
const { can } = useAuth()
|
||||
return can(permission)
|
||||
}
|
||||
|
||||
// Hook for checking a specific role
|
||||
export function useRole(role: UserRole): boolean {
|
||||
const { isAtLeast } = useAuth()
|
||||
return isAtLeast(role)
|
||||
}
|
||||
|
||||
// Hook for requiring authentication (with redirect)
|
||||
export function useRequireAuth(options?: { redirectTo?: string }) {
|
||||
const { isAuthenticated, isLoading } = useAuth()
|
||||
|
||||
if (!isLoading && !isAuthenticated && typeof window !== 'undefined') {
|
||||
const redirectTo = options?.redirectTo || '/auth/signin'
|
||||
window.location.href = `${redirectTo}?callbackUrl=${encodeURIComponent(window.location.pathname)}`
|
||||
}
|
||||
|
||||
return { isAuthenticated, isLoading }
|
||||
}
|
||||
180
lib/auth/withAuth.ts
Normal file
180
lib/auth/withAuth.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { NextApiRequest, NextApiResponse, NextApiHandler } from 'next'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import { authOptions } from '@/pages/api/auth/[...nextauth]'
|
||||
import { UserRole, AuthUser } from './types'
|
||||
import { hasPermission, hasRole } from './permissions'
|
||||
|
||||
export interface AuthenticatedRequest extends NextApiRequest {
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
type AuthenticatedHandler = (
|
||||
req: AuthenticatedRequest,
|
||||
res: NextApiResponse
|
||||
) => Promise<void> | void
|
||||
|
||||
interface WithAuthOptions {
|
||||
requiredRole?: UserRole
|
||||
requiredPermission?: string
|
||||
requiredPermissions?: string[]
|
||||
requireAll?: boolean // If true, requires all permissions; if false, requires any
|
||||
}
|
||||
|
||||
export function withAuth(
|
||||
handler: AuthenticatedHandler,
|
||||
options?: WithAuthOptions
|
||||
): NextApiHandler {
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'You must be signed in to access this resource',
|
||||
})
|
||||
}
|
||||
|
||||
const user: AuthUser = {
|
||||
id: session.user.id,
|
||||
email: session.user.email!,
|
||||
name: session.user.name,
|
||||
image: session.user.image,
|
||||
role: session.user.role || UserRole.USER,
|
||||
emailVerified: session.user.emailVerified,
|
||||
}
|
||||
|
||||
// Check role requirement
|
||||
if (options?.requiredRole) {
|
||||
if (!hasRole(user.role, options.requiredRole)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `This resource requires ${options.requiredRole} role or higher`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check single permission
|
||||
if (options?.requiredPermission) {
|
||||
if (!hasPermission(user.role, options.requiredPermission)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `You do not have the required permission: ${options.requiredPermission}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check multiple permissions
|
||||
if (options?.requiredPermissions && options.requiredPermissions.length > 0) {
|
||||
const checkFunction = options.requireAll
|
||||
? (perms: string[]) => perms.every(p => hasPermission(user.role, p))
|
||||
: (perms: string[]) => perms.some(p => hasPermission(user.role, p))
|
||||
|
||||
if (!checkFunction(options.requiredPermissions)) {
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: options.requireAll
|
||||
? `You need all of these permissions: ${options.requiredPermissions.join(', ')}`
|
||||
: `You need at least one of these permissions: ${options.requiredPermissions.join(', ')}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add user to request
|
||||
const authReq = req as AuthenticatedRequest
|
||||
authReq.user = user
|
||||
|
||||
return handler(authReq, res)
|
||||
} catch (error) {
|
||||
console.error('Auth middleware error:', error)
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An error occurred while authenticating your request',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience wrapper for role-based protection
|
||||
export function withRole(
|
||||
handler: AuthenticatedHandler,
|
||||
role: UserRole
|
||||
): NextApiHandler {
|
||||
return withAuth(handler, { requiredRole: role })
|
||||
}
|
||||
|
||||
// Convenience wrapper for permission-based protection
|
||||
export function withPermission(
|
||||
handler: AuthenticatedHandler,
|
||||
permission: string
|
||||
): NextApiHandler {
|
||||
return withAuth(handler, { requiredPermission: permission })
|
||||
}
|
||||
|
||||
// Convenience wrapper for multiple permissions (any)
|
||||
export function withAnyPermission(
|
||||
handler: AuthenticatedHandler,
|
||||
permissions: string[]
|
||||
): NextApiHandler {
|
||||
return withAuth(handler, { requiredPermissions: permissions, requireAll: false })
|
||||
}
|
||||
|
||||
// Convenience wrapper for multiple permissions (all)
|
||||
export function withAllPermissions(
|
||||
handler: AuthenticatedHandler,
|
||||
permissions: string[]
|
||||
): NextApiHandler {
|
||||
return withAuth(handler, { requiredPermissions: permissions, requireAll: true })
|
||||
}
|
||||
|
||||
// Rate limiting helper (basic implementation)
|
||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
export function checkRateLimit(
|
||||
identifier: string,
|
||||
limit: number = 100,
|
||||
windowMs: number = 60000
|
||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||
const now = Date.now()
|
||||
const record = rateLimitMap.get(identifier)
|
||||
|
||||
if (!record || now > record.resetAt) {
|
||||
rateLimitMap.set(identifier, { count: 1, resetAt: now + windowMs })
|
||||
return { allowed: true, remaining: limit - 1, resetAt: now + windowMs }
|
||||
}
|
||||
|
||||
if (record.count >= limit) {
|
||||
return { allowed: false, remaining: 0, resetAt: record.resetAt }
|
||||
}
|
||||
|
||||
record.count++
|
||||
return { allowed: true, remaining: limit - record.count, resetAt: record.resetAt }
|
||||
}
|
||||
|
||||
export function withRateLimit(
|
||||
handler: NextApiHandler,
|
||||
options: { limit?: number; windowMs?: number; keyGenerator?: (req: NextApiRequest) => string }
|
||||
): NextApiHandler {
|
||||
const limit = options.limit || 100
|
||||
const windowMs = options.windowMs || 60000
|
||||
const keyGenerator = options.keyGenerator || ((req) => req.socket.remoteAddress || 'unknown')
|
||||
|
||||
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const key = keyGenerator(req)
|
||||
const { allowed, remaining, resetAt } = checkRateLimit(key, limit, windowMs)
|
||||
|
||||
res.setHeader('X-RateLimit-Limit', limit)
|
||||
res.setHeader('X-RateLimit-Remaining', remaining)
|
||||
res.setHeader('X-RateLimit-Reset', Math.ceil(resetAt / 1000))
|
||||
|
||||
if (!allowed) {
|
||||
return res.status(429).json({
|
||||
error: 'Too Many Requests',
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
retryAfter: Math.ceil((resetAt - Date.now()) / 1000),
|
||||
})
|
||||
}
|
||||
|
||||
return handler(req, res)
|
||||
}
|
||||
}
|
||||
51
lib/auth/withRole.ts
Normal file
51
lib/auth/withRole.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextApiHandler } from 'next'
|
||||
import { UserRole } from './types'
|
||||
import { withAuth, AuthenticatedHandler } from './withAuth'
|
||||
|
||||
/**
|
||||
* Protect an API route requiring a specific role or higher
|
||||
*
|
||||
* @example
|
||||
* // Only admins can access
|
||||
* export default requireRole(handler, UserRole.ADMIN)
|
||||
*
|
||||
* // Growers and above can access
|
||||
* export default requireRole(handler, UserRole.GROWER)
|
||||
*/
|
||||
export function requireRole(
|
||||
handler: AuthenticatedHandler,
|
||||
role: UserRole
|
||||
): NextApiHandler {
|
||||
return withAuth(handler, { requiredRole: role })
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect an API route requiring admin role
|
||||
*/
|
||||
export function requireAdmin(handler: AuthenticatedHandler): NextApiHandler {
|
||||
return requireRole(handler, UserRole.ADMIN)
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect an API route requiring farm manager role or higher
|
||||
*/
|
||||
export function requireFarmManager(handler: AuthenticatedHandler): NextApiHandler {
|
||||
return requireRole(handler, UserRole.FARM_MANAGER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect an API route requiring grower role or higher
|
||||
*/
|
||||
export function requireGrower(handler: AuthenticatedHandler): NextApiHandler {
|
||||
return requireRole(handler, UserRole.GROWER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Protect an API route requiring any authenticated user
|
||||
*/
|
||||
export function requireUser(handler: AuthenticatedHandler): NextApiHandler {
|
||||
return withAuth(handler)
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export { withAuth, withPermission, withAnyPermission, withAllPermissions } from './withAuth'
|
||||
|
|
@ -18,13 +18,17 @@
|
|||
"test:e2e:ci": "start-server-and-test 'bun run preview' http://localhost:3001 cy:run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^7.0.0",
|
||||
"@tailwindcss/forms": "^0.4.0",
|
||||
"@tailwindcss/typography": "^0.5.1",
|
||||
"@tanstack/react-query": "^4.0.10",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"drupal-jsonapi-params": "^1.2.2",
|
||||
"html-react-parser": "^1.2.7",
|
||||
"next": "^12.2.3",
|
||||
"next-auth": "^4.24.13",
|
||||
"next-drupal": "^1.6.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "^17.0.2",
|
||||
|
|
@ -34,6 +38,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jest": "^29.5.0",
|
||||
"@types/node": "^17.0.21",
|
||||
"@types/react": "^17.0.0",
|
||||
|
|
@ -41,6 +46,7 @@
|
|||
"eslint-config-next": "^12.0.10",
|
||||
"jest": "^29.5.0",
|
||||
"postcss": "^8.4.5",
|
||||
"prisma": "^7.0.0",
|
||||
"tailwindcss": "^3.0.15",
|
||||
"ts-jest": "^29.1.0",
|
||||
"typescript": "^4.5.5"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as React from "react"
|
||||
import Router from "next/router"
|
||||
import { QueryClient, QueryClientProvider, Hydrate } from "@tanstack/react-query"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
import NProgress from "nprogress"
|
||||
import { syncDrupalPreviewRoutes } from "next-drupal"
|
||||
import "nprogress/nprogress.css"
|
||||
|
|
@ -16,16 +17,18 @@ Router.events.on("routeChangeStart", function (path) {
|
|||
Router.events.on("routeChangeComplete", () => NProgress.done())
|
||||
Router.events.on("routeChangeError", () => NProgress.done())
|
||||
|
||||
export default function App({ Component, pageProps }) {
|
||||
export default function App({ Component, pageProps: { session, ...pageProps } }) {
|
||||
const queryClientRef = React.useRef<QueryClient>()
|
||||
if (!queryClientRef.current) {
|
||||
queryClientRef.current = new QueryClient()
|
||||
}
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<QueryClientProvider client={queryClientRef.current}>
|
||||
<Hydrate state={pageProps.dehydratedState}>
|
||||
<Component {...pageProps} />
|
||||
</Hydrate>
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
191
pages/api/auth/[...nextauth].ts
Normal file
191
pages/api/auth/[...nextauth].ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import NextAuth, { NextAuthOptions } from 'next-auth'
|
||||
import CredentialsProvider from 'next-auth/providers/credentials'
|
||||
import GitHubProvider from 'next-auth/providers/github'
|
||||
import GoogleProvider from 'next-auth/providers/google'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { UserRole } from '@/lib/auth/types'
|
||||
|
||||
// In-memory user store for MVP (will be replaced with Prisma in Agent 2)
|
||||
// This simulates database operations
|
||||
interface StoredUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string | null
|
||||
passwordHash: string | null
|
||||
role: UserRole
|
||||
emailVerified: Date | null
|
||||
image: string | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
// Temporary in-memory store (will be replaced with database)
|
||||
const users: Map<string, StoredUser> = new Map()
|
||||
|
||||
// Helper to find user by email
|
||||
function findUserByEmail(email: string): StoredUser | undefined {
|
||||
for (const user of users.values()) {
|
||||
if (user.email.toLowerCase() === email.toLowerCase()) {
|
||||
return user
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Helper to create user
|
||||
export function createUser(data: {
|
||||
email: string
|
||||
name?: string
|
||||
passwordHash?: string
|
||||
role?: UserRole
|
||||
}): StoredUser {
|
||||
const id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
const user: StoredUser = {
|
||||
id,
|
||||
email: data.email.toLowerCase(),
|
||||
name: data.name || null,
|
||||
passwordHash: data.passwordHash || null,
|
||||
role: data.role || UserRole.USER,
|
||||
emailVerified: null,
|
||||
image: null,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
users.set(id, user)
|
||||
return user
|
||||
}
|
||||
|
||||
// Helper to get user by ID
|
||||
export function getUserById(id: string): StoredUser | undefined {
|
||||
return users.get(id)
|
||||
}
|
||||
|
||||
// Helper to verify password
|
||||
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: 'Credentials',
|
||||
credentials: {
|
||||
email: { label: 'Email', type: 'email', placeholder: 'your@email.com' },
|
||||
password: { label: 'Password', type: 'password' },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
throw new Error('Email and password are required')
|
||||
}
|
||||
|
||||
const user = findUserByEmail(credentials.email)
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user found with this email')
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new Error('Please sign in with your OAuth provider')
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.passwordHash)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid password')
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
}
|
||||
},
|
||||
}),
|
||||
...(process.env.GITHUB_ID && process.env.GITHUB_SECRET
|
||||
? [
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
signOut: '/auth/signout',
|
||||
error: '/auth/error',
|
||||
verifyRequest: '/auth/verify-request',
|
||||
newUser: '/auth/new-user',
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
// Handle OAuth sign-in
|
||||
if (account?.provider !== 'credentials') {
|
||||
const existingUser = findUserByEmail(user.email!)
|
||||
if (!existingUser) {
|
||||
// Create new user for OAuth sign-in
|
||||
createUser({
|
||||
email: user.email!,
|
||||
name: user.name || undefined,
|
||||
role: UserRole.USER,
|
||||
})
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
// Initial sign-in
|
||||
if (user) {
|
||||
token.id = user.id
|
||||
token.role = user.role || UserRole.USER
|
||||
token.emailVerified = user.emailVerified
|
||||
}
|
||||
|
||||
// Handle session update
|
||||
if (trigger === 'update' && session) {
|
||||
token.name = session.name
|
||||
token.role = session.role
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id
|
||||
session.user.role = token.role
|
||||
session.user.emailVerified = token.emailVerified
|
||||
}
|
||||
return session
|
||||
},
|
||||
},
|
||||
events: {
|
||||
async signIn({ user, isNewUser }) {
|
||||
console.log(`User signed in: ${user.email}, isNewUser: ${isNewUser}`)
|
||||
},
|
||||
async signOut({ token }) {
|
||||
console.log(`User signed out: ${token.email}`)
|
||||
},
|
||||
},
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
}
|
||||
|
||||
export default NextAuth(authOptions)
|
||||
|
||||
// Export helper for use in registration API
|
||||
export { findUserByEmail, verifyPassword }
|
||||
102
pages/api/auth/forgot-password.ts
Normal file
102
pages/api/auth/forgot-password.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import crypto from 'crypto'
|
||||
import { findUserByEmail } from './[...nextauth]'
|
||||
import { AuthResponse, ForgotPasswordInput, TokenPayload } from '@/lib/auth/types'
|
||||
import { withRateLimit } from '@/lib/auth/withAuth'
|
||||
|
||||
// In-memory token store (will be replaced with database in Agent 2)
|
||||
const passwordResetTokens = new Map<string, TokenPayload>()
|
||||
|
||||
// Token expiry: 1 hour
|
||||
const TOKEN_EXPIRY_MS = 60 * 60 * 1000
|
||||
|
||||
function generateResetToken(userId: string, email: string): string {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
email,
|
||||
type: 'password_reset',
|
||||
expiresAt: Date.now() + TOKEN_EXPIRY_MS,
|
||||
}
|
||||
passwordResetTokens.set(token, payload)
|
||||
return token
|
||||
}
|
||||
|
||||
export function verifyResetToken(token: string): TokenPayload | null {
|
||||
const payload = passwordResetTokens.get(token)
|
||||
if (!payload) return null
|
||||
if (Date.now() > payload.expiresAt) {
|
||||
passwordResetTokens.delete(token)
|
||||
return null
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export function invalidateResetToken(token: string): void {
|
||||
passwordResetTokens.delete(token)
|
||||
}
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AuthResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
message: 'Method not allowed',
|
||||
error: 'Only POST requests are accepted',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { email }: ForgotPasswordInput = req.body
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email is required',
|
||||
error: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
// Always return success to prevent email enumeration attacks
|
||||
const successResponse: AuthResponse = {
|
||||
success: true,
|
||||
message: 'If an account exists with this email, you will receive a password reset link.',
|
||||
}
|
||||
|
||||
const user = findUserByEmail(email)
|
||||
if (!user) {
|
||||
// Return success even if user doesn't exist (security best practice)
|
||||
return res.status(200).json(successResponse)
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const resetToken = generateResetToken(user.id, user.email)
|
||||
|
||||
// Build reset URL
|
||||
const baseUrl = process.env.NEXTAUTH_URL || `http://${req.headers.host}`
|
||||
const resetUrl = `${baseUrl}/auth/reset-password?token=${resetToken}`
|
||||
|
||||
// TODO: Send email with reset link (will be implemented with Agent 8 - Notifications)
|
||||
// For now, log the reset URL (in development only)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Password reset link for ${email}: ${resetUrl}`)
|
||||
}
|
||||
|
||||
return res.status(200).json(successResponse)
|
||||
} catch (error) {
|
||||
console.error('Forgot password error:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'An error occurred processing your request',
|
||||
error: 'INTERNAL_ERROR',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rate limiting: 3 requests per minute per IP
|
||||
export default withRateLimit(handler, {
|
||||
limit: 3,
|
||||
windowMs: 60000,
|
||||
})
|
||||
119
pages/api/auth/register.ts
Normal file
119
pages/api/auth/register.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { createUser, findUserByEmail } from './[...nextauth]'
|
||||
import { UserRole, AuthResponse, RegisterInput } from '@/lib/auth/types'
|
||||
import { withRateLimit } from '@/lib/auth/withAuth'
|
||||
|
||||
const BCRYPT_ROUNDS = 12 // Secure password hashing
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AuthResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
message: 'Method not allowed',
|
||||
error: 'Only POST requests are accepted',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { email, password, name, role }: RegisterInput = req.body
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email and password are required',
|
||||
error: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
// Email format validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid email format',
|
||||
error: 'INVALID_EMAIL',
|
||||
})
|
||||
}
|
||||
|
||||
// Password strength validation
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password must be at least 8 characters long',
|
||||
error: 'WEAK_PASSWORD',
|
||||
})
|
||||
}
|
||||
|
||||
// Check password complexity
|
||||
const hasUpperCase = /[A-Z]/.test(password)
|
||||
const hasLowerCase = /[a-z]/.test(password)
|
||||
const hasNumbers = /\d/.test(password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password must contain uppercase, lowercase, and numbers',
|
||||
error: 'WEAK_PASSWORD',
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = findUserByEmail(email)
|
||||
if (existingUser) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'An account with this email already exists',
|
||||
error: 'USER_EXISTS',
|
||||
})
|
||||
}
|
||||
|
||||
// Hash password with bcrypt
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
|
||||
// Determine role (default to USER, only admin can assign higher roles)
|
||||
// In a real app, you'd check if the requester has admin permissions
|
||||
const userRole = role === UserRole.ADMIN ? UserRole.USER : (role || UserRole.USER)
|
||||
|
||||
// Create user
|
||||
const user = createUser({
|
||||
email: email.toLowerCase(),
|
||||
name: name || undefined,
|
||||
passwordHash,
|
||||
role: userRole,
|
||||
})
|
||||
|
||||
// TODO: Send verification email (will be implemented with Agent 8 - Notifications)
|
||||
console.log(`New user registered: ${user.email}`)
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: 'Registration successful. Please sign in.',
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
image: user.image,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'An error occurred during registration',
|
||||
error: 'INTERNAL_ERROR',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rate limiting: 5 registrations per minute per IP
|
||||
export default withRateLimit(handler, {
|
||||
limit: 5,
|
||||
windowMs: 60000,
|
||||
})
|
||||
106
pages/api/auth/reset-password.ts
Normal file
106
pages/api/auth/reset-password.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { verifyResetToken, invalidateResetToken } from './forgot-password'
|
||||
import { getUserById } from './[...nextauth]'
|
||||
import { AuthResponse, ResetPasswordInput } from '@/lib/auth/types'
|
||||
import { withRateLimit } from '@/lib/auth/withAuth'
|
||||
|
||||
const BCRYPT_ROUNDS = 12
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AuthResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
message: 'Method not allowed',
|
||||
error: 'Only POST requests are accepted',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { token, password }: ResetPasswordInput = req.body
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Token and new password are required',
|
||||
error: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password must be at least 8 characters long',
|
||||
error: 'WEAK_PASSWORD',
|
||||
})
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(password)
|
||||
const hasLowerCase = /[a-z]/.test(password)
|
||||
const hasNumbers = /\d/.test(password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password must contain uppercase, lowercase, and numbers',
|
||||
error: 'WEAK_PASSWORD',
|
||||
})
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = verifyResetToken(token)
|
||||
if (!payload) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid or expired reset token',
|
||||
error: 'INVALID_TOKEN',
|
||||
})
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = getUserById(payload.userId)
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
error: 'USER_NOT_FOUND',
|
||||
})
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
|
||||
|
||||
// Update user password (in-memory for now)
|
||||
user.passwordHash = passwordHash
|
||||
|
||||
// Invalidate the used token
|
||||
invalidateResetToken(token)
|
||||
|
||||
// TODO: Invalidate all existing sessions for this user (security best practice)
|
||||
// TODO: Send confirmation email
|
||||
|
||||
console.log(`Password reset successful for user: ${user.email}`)
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Password reset successful. You can now sign in with your new password.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Reset password error:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'An error occurred resetting your password',
|
||||
error: 'INTERNAL_ERROR',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rate limiting: 5 requests per minute per IP
|
||||
export default withRateLimit(handler, {
|
||||
limit: 5,
|
||||
windowMs: 60000,
|
||||
})
|
||||
151
pages/api/auth/verify-email.ts
Normal file
151
pages/api/auth/verify-email.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import crypto from 'crypto'
|
||||
import { findUserByEmail, getUserById } from './[...nextauth]'
|
||||
import { AuthResponse, VerifyEmailInput, TokenPayload } from '@/lib/auth/types'
|
||||
import { withRateLimit } from '@/lib/auth/withAuth'
|
||||
|
||||
// In-memory token store (will be replaced with database in Agent 2)
|
||||
const emailVerificationTokens = new Map<string, TokenPayload>()
|
||||
|
||||
// Token expiry: 24 hours
|
||||
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
export function generateVerificationToken(userId: string, email: string): string {
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
email,
|
||||
type: 'email_verification',
|
||||
expiresAt: Date.now() + TOKEN_EXPIRY_MS,
|
||||
}
|
||||
emailVerificationTokens.set(token, payload)
|
||||
return token
|
||||
}
|
||||
|
||||
function verifyToken(token: string): TokenPayload | null {
|
||||
const payload = emailVerificationTokens.get(token)
|
||||
if (!payload) return null
|
||||
if (Date.now() > payload.expiresAt) {
|
||||
emailVerificationTokens.delete(token)
|
||||
return null
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function invalidateToken(token: string): void {
|
||||
emailVerificationTokens.delete(token)
|
||||
}
|
||||
|
||||
async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AuthResponse>
|
||||
) {
|
||||
// Handle GET request (verify token from email link)
|
||||
if (req.method === 'GET') {
|
||||
const { token } = req.query
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Verification token is required',
|
||||
error: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
const payload = verifyToken(token)
|
||||
if (!payload) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Invalid or expired verification token',
|
||||
error: 'INVALID_TOKEN',
|
||||
})
|
||||
}
|
||||
|
||||
const user = getUserById(payload.userId)
|
||||
if (!user) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
error: 'USER_NOT_FOUND',
|
||||
})
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
user.emailVerified = new Date()
|
||||
|
||||
// Invalidate the used token
|
||||
invalidateToken(token)
|
||||
|
||||
console.log(`Email verified for user: ${user.email}`)
|
||||
|
||||
// Redirect to success page or return success response
|
||||
if (req.headers.accept?.includes('text/html')) {
|
||||
res.redirect(302, '/auth/email-verified')
|
||||
return
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email verified successfully',
|
||||
})
|
||||
}
|
||||
|
||||
// Handle POST request (resend verification email)
|
||||
if (req.method === 'POST') {
|
||||
const { email } = req.body
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email is required',
|
||||
error: 'VALIDATION_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
const user = findUserByEmail(email)
|
||||
if (!user) {
|
||||
// Return success to prevent email enumeration
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a verification link has been sent.',
|
||||
})
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Email is already verified',
|
||||
error: 'ALREADY_VERIFIED',
|
||||
})
|
||||
}
|
||||
|
||||
// Generate new verification token
|
||||
const verificationToken = generateVerificationToken(user.id, user.email)
|
||||
|
||||
// Build verification URL
|
||||
const baseUrl = process.env.NEXTAUTH_URL || `http://${req.headers.host}`
|
||||
const verifyUrl = `${baseUrl}/api/auth/verify-email?token=${verificationToken}`
|
||||
|
||||
// TODO: Send verification email (will be implemented with Agent 8 - Notifications)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Email verification link for ${email}: ${verifyUrl}`)
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'If an account exists with this email, a verification link has been sent.',
|
||||
})
|
||||
}
|
||||
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
message: 'Method not allowed',
|
||||
error: 'Only GET and POST requests are accepted',
|
||||
})
|
||||
}
|
||||
|
||||
// Apply rate limiting: 3 requests per minute per IP
|
||||
export default withRateLimit(handler, {
|
||||
limit: 3,
|
||||
windowMs: 60000,
|
||||
})
|
||||
52
pages/auth/email-verified.tsx
Normal file
52
pages/auth/email-verified.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import Layout from '@/components/layout'
|
||||
|
||||
export default function EmailVerified() {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Email Verified | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg">
|
||||
<div className="flex justify-center mb-4">
|
||||
<svg
|
||||
className="h-16 w-16 text-green-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">Email Verified!</h2>
|
||||
<p className="mb-4">
|
||||
Your email address has been successfully verified. You now have full access to LocalGreenChain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Link href="/">
|
||||
<a className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 w-full justify-center">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</Link>
|
||||
<Link href="/plants/register">
|
||||
<a className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 w-full justify-center">
|
||||
Register a Plant
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
156
pages/auth/forgot-password.tsx
Normal file
156
pages/auth/forgot-password.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState } from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { authOptions } from '../api/auth/[...nextauth]'
|
||||
import Layout from '@/components/layout'
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'An error occurred')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Check Your Email | LocalGreenChain</title>
|
||||
</Head>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-2">Check Your Email</h2>
|
||||
<p className="mb-4">
|
||||
If an account exists with that email address, we've sent you a link to reset your password.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Didn't receive the email? Check your spam folder or{' '}
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="text-green-600 hover:text-green-500 underline"
|
||||
>
|
||||
try again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/auth/signin">
|
||||
<a className="text-green-600 hover:text-green-500 font-medium">
|
||||
Back to sign in
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Forgot Password | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Reset your password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send reset link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/auth/signin">
|
||||
<a className="text-sm text-green-600 hover:text-green-500">
|
||||
Back to sign in
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
}
|
||||
}
|
||||
216
pages/auth/reset-password.tsx
Normal file
216
pages/auth/reset-password.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { authOptions } from '../api/auth/[...nextauth]'
|
||||
import Layout from '@/components/layout'
|
||||
|
||||
export default function ResetPassword() {
|
||||
const router = useRouter()
|
||||
const { token } = router.query
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady && !token) {
|
||||
setError('Invalid or missing reset token')
|
||||
}
|
||||
}, [router.isReady, token])
|
||||
|
||||
const validatePassword = (): string | null => {
|
||||
if (password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(password)
|
||||
const hasLowerCase = /[a-z]/.test(password)
|
||||
const hasNumbers = /\d/.test(password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validatePassword()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'An error occurred')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Password Reset Successful | LocalGreenChain</title>
|
||||
</Head>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-2">Password Reset Successful!</h2>
|
||||
<p className="mb-4">
|
||||
Your password has been reset successfully. You can now sign in with your new password.
|
||||
</p>
|
||||
<Link href="/auth/signin">
|
||||
<a className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Reset Password | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Set new password
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Enter your new password below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
{error.includes('Invalid') && (
|
||||
<div className="mt-2">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a className="text-red-700 underline hover:text-red-800">
|
||||
Request a new reset link
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !token}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Resetting...' : 'Reset password'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/auth/signin">
|
||||
<a className="text-sm text-green-600 hover:text-green-500">
|
||||
Back to sign in
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
}
|
||||
}
|
||||
225
pages/auth/signin.tsx
Normal file
225
pages/auth/signin.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { useState } from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getProviders, signIn, getCsrfToken } from 'next-auth/react'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { authOptions } from '../api/auth/[...nextauth]'
|
||||
import Layout from '@/components/layout'
|
||||
|
||||
interface SignInProps {
|
||||
providers: Awaited<ReturnType<typeof getProviders>>
|
||||
csrfToken: string | undefined
|
||||
}
|
||||
|
||||
export default function SignIn({ providers, csrfToken }: SignInProps) {
|
||||
const router = useRouter()
|
||||
const { callbackUrl, error } = router.query
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [authError, setAuthError] = useState<string | null>(
|
||||
error ? getErrorMessage(error as string) : null
|
||||
)
|
||||
|
||||
const handleCredentialsSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setAuthError(null)
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setAuthError(result.error)
|
||||
} else if (result?.ok) {
|
||||
router.push((callbackUrl as string) || '/')
|
||||
}
|
||||
} catch (err) {
|
||||
setAuthError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOAuthSignIn = (providerId: string) => {
|
||||
signIn(providerId, { callbackUrl: (callbackUrl as string) || '/' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Sign In | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link href="/auth/signup">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">
|
||||
create a new account
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{authError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleCredentialsSubmit}>
|
||||
<input type="hidden" name="csrfToken" defaultValue={csrfToken} />
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-green-600 focus:ring-green-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link href="/auth/forgot-password">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{providers && Object.values(providers).filter(p => p.id !== 'credentials').length > 0 && (
|
||||
<>
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-gray-50 text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
{Object.values(providers)
|
||||
.filter((provider) => provider.id !== 'credentials')
|
||||
.map((provider) => (
|
||||
<button
|
||||
key={provider.name}
|
||||
onClick={() => handleOAuthSignIn(provider.id)}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span>{provider.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
function getErrorMessage(error: string): string {
|
||||
const errorMessages: Record<string, string> = {
|
||||
Signin: 'Try signing in with a different account.',
|
||||
OAuthSignin: 'Try signing in with a different account.',
|
||||
OAuthCallback: 'Try signing in with a different account.',
|
||||
OAuthCreateAccount: 'Try signing in with a different account.',
|
||||
EmailCreateAccount: 'Try signing in with a different account.',
|
||||
Callback: 'Try signing in with a different account.',
|
||||
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
|
||||
EmailSignin: 'Check your email address.',
|
||||
CredentialsSignin: 'Sign in failed. Check the details you provided are correct.',
|
||||
default: 'Unable to sign in.',
|
||||
}
|
||||
return errorMessages[error] ?? errorMessages.default
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providers = await getProviders()
|
||||
const csrfToken = await getCsrfToken(context)
|
||||
|
||||
return {
|
||||
props: {
|
||||
providers: providers ?? null,
|
||||
csrfToken: csrfToken ?? null,
|
||||
},
|
||||
}
|
||||
}
|
||||
270
pages/auth/signup.tsx
Normal file
270
pages/auth/signup.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { useState } from 'react'
|
||||
import { GetServerSideProps } from 'next'
|
||||
import { getServerSession } from 'next-auth/next'
|
||||
import { signIn } from 'next-auth/react'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { authOptions } from '../api/auth/[...nextauth]'
|
||||
import Layout from '@/components/layout'
|
||||
|
||||
export default function SignUp() {
|
||||
const router = useRouter()
|
||||
const { callbackUrl } = router.query
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[e.target.name]: e.target.value,
|
||||
}))
|
||||
}
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
if (!formData.email || !formData.password) {
|
||||
return 'Email and password are required'
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
return 'Password must be at least 8 characters long'
|
||||
}
|
||||
|
||||
const hasUpperCase = /[A-Z]/.test(formData.password)
|
||||
const hasLowerCase = /[a-z]/.test(formData.password)
|
||||
const hasNumbers = /\d/.test(formData.password)
|
||||
|
||||
if (!hasUpperCase || !hasLowerCase || !hasNumbers) {
|
||||
return 'Password must contain uppercase, lowercase, and numbers'
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
return 'Passwords do not match'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'Registration failed')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
|
||||
// Auto sign in after successful registration
|
||||
setTimeout(async () => {
|
||||
const result = await signIn('credentials', {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.ok) {
|
||||
router.push((callbackUrl as string) || '/')
|
||||
} else {
|
||||
router.push('/auth/signin')
|
||||
}
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Registration Successful | LocalGreenChain</title>
|
||||
</Head>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-2">Registration Successful!</h2>
|
||||
<p>Your account has been created. Signing you in...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Sign Up | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Create your account
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link href="/auth/signin">
|
||||
<a className="font-medium text-green-600 hover:text-green-500">
|
||||
Sign in
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Full Name (optional)
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="At least 8 characters"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Must contain uppercase, lowercase, and numbers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="Confirm your password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Creating account...' : 'Create account'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-xs text-gray-500">
|
||||
By creating an account, you agree to our{' '}
|
||||
<a href="#" className="text-green-600 hover:text-green-500">
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="#" className="text-green-600 hover:text-green-500">
|
||||
Privacy Policy
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
const session = await getServerSession(context.req, context.res, authOptions)
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/',
|
||||
permanent: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
props: {},
|
||||
}
|
||||
}
|
||||
156
pages/auth/verify-email.tsx
Normal file
156
pages/auth/verify-email.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState } from 'react'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import Layout from '@/components/layout'
|
||||
import { useAuth } from '@/lib/auth/useAuth'
|
||||
|
||||
export default function VerifyEmail() {
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
const emailToVerify = isAuthenticated ? user?.email : email
|
||||
|
||||
if (!emailToVerify) {
|
||||
setError('Email address is required')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/verify-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailToVerify }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.message || 'An error occurred')
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Verification Email Sent | LocalGreenChain</title>
|
||||
</Head>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-8 rounded-lg">
|
||||
<h2 className="text-2xl font-bold mb-2">Check Your Email</h2>
|
||||
<p className="mb-4">
|
||||
If an account exists with that email address, we've sent you a verification link.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Didn't receive the email? Check your spam folder or{' '}
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="text-green-600 hover:text-green-500 underline"
|
||||
>
|
||||
try again
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/">
|
||||
<a className="text-green-600 hover:text-green-500 font-medium">
|
||||
Go to homepage
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>Verify Email | LocalGreenChain</title>
|
||||
</Head>
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Verify your email
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
{isAuthenticated
|
||||
? 'Click the button below to resend a verification email.'
|
||||
: 'Enter your email address to receive a verification link.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{isAuthenticated ? (
|
||||
<div className="bg-gray-50 border border-gray-200 px-4 py-3 rounded">
|
||||
<p className="text-sm text-gray-600">
|
||||
Logged in as: <span className="font-medium">{user?.email}</span>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send verification email'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/">
|
||||
<a className="text-sm text-green-600 hover:text-green-500">
|
||||
Go to homepage
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,10 @@
|
|||
"jsx": "preserve",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/components/*": ["src/components/*"],
|
||||
"@/*": ["./*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/lib/*": ["lib/*"],
|
||||
"@/pages/*": ["pages/*"],
|
||||
"@/nodes/*": ["src/nodes/*"],
|
||||
"@/paragraphs/*": ["src/paragraphs/*"],
|
||||
"@/views/*": ["src/views/*"],
|
||||
|
|
|
|||
Loading…
Reference in a new issue