Merge: Comprehensive authentication system (Agent 1) - resolved conflicts

This commit is contained in:
Vinnie Esposito 2025-11-23 10:58:00 -06:00
commit 9e046b1ae5
29 changed files with 3397 additions and 9 deletions

View file

@ -8,6 +8,18 @@
# Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=SCHEMA
DATABASE_URL="postgresql://postgres:password@localhost:5432/localgreenchain?schema=public"
# ===========================================
# AUTHENTICATION (NextAuth.js)
# ===========================================
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
# ===========================================
# EXTERNAL SERVICES
# ===========================================

206
bun.lock
View file

@ -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=="],

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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'

View file

@ -24,14 +24,17 @@
"db:studio": "prisma studio"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.7.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",
@ -41,6 +44,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",

View file

@ -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 (
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
<SessionProvider session={session}>
<QueryClientProvider client={queryClientRef.current}>
<Hydrate state={pageProps.dehydratedState}>
<Component {...pageProps} />
</Hydrate>
</QueryClientProvider>
</SessionProvider>
)
}

View 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 }

View 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
View 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,
})

View 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,
})

View 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,
})

View 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>
)
}

View 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: {},
}
}

View 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
View 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
View 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
View 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>
)
}

View file

@ -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/*"],