initial commit
This commit is contained in:
parent
96e8a993fc
commit
3141ba60f4
536
package-lock.json
generated
536
package-lock.json
generated
@ -8,7 +8,12 @@
|
||||
"name": "strichliste-fsmni",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.3",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@ -20,6 +25,7 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
@ -51,6 +57,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
|
||||
@ -225,6 +240,79 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
||||
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
|
||||
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.0",
|
||||
"@floating-ui/utils": "^0.2.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.26.28",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
|
||||
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.2",
|
||||
"@floating-ui/utils": "^0.2.8",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
|
||||
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@headlessui/react": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.3.tgz",
|
||||
"integrity": "sha512-hgOJGXPifPlOczIeSwX8OjLWRJ5XdYApZFf7DeCbCrO1PXHkPhNTRrA9ZwJsgAG7SON1i2JcvIreF/kbgtJeaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.16",
|
||||
"@react-aria/focus": "^3.20.2",
|
||||
"@react-aria/interactions": "^3.25.0",
|
||||
"@tanstack/react-virtual": "^3.13.6",
|
||||
"use-sync-external-store": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -747,6 +835,16 @@
|
||||
"@tybys/wasm-util": "^0.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@next-auth/prisma-adapter": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@next-auth/prisma-adapter/-/prisma-adapter-1.0.7.tgz",
|
||||
"integrity": "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@prisma/client": ">=2.26.0 || >=3",
|
||||
"next-auth": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz",
|
||||
@ -939,6 +1037,194 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.8.2.tgz",
|
||||
"integrity": "sha512-5II+vbyzv4si6Yunwgkj0qT/iY0zyspttoDrL3R4BYgLdp42/d2C8xdi9vqkrYtKt9H32oFIukvyw3Koz5JoDg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prisma": "*",
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"prisma": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.8.2.tgz",
|
||||
"integrity": "sha512-ZJY1fF4qRBPdLQ/60wxNtX+eu89c3AkYEcP7L3jkp0IPXCNphCYxikTg55kPJLDOG6P0X+QG5tCv6CmsBRZWFQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jiti": "2.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.8.2.tgz",
|
||||
"integrity": "sha512-4muBSSUwJJ9BYth5N8tqts8JtiLT8QI/RSAzEogwEfpbYGFo9mYsInsVo8dqXdPO2+Rm5OG5q0qWDDE3nyUbVg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.8.2.tgz",
|
||||
"integrity": "sha512-XqAJ//LXjqYRQ1RRabs79KOY4+v6gZOGzbcwDQl0D6n9WBKjV7qdrbd042CwSK0v0lM9MSHsbcFnU2Yn7z8Zlw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.8.2",
|
||||
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
|
||||
"@prisma/fetch-engine": "6.8.2",
|
||||
"@prisma/get-platform": "6.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e.tgz",
|
||||
"integrity": "sha512-Rkik9lMyHpFNGaLpPF3H5q5TQTkm/aE7DsGM5m92FZTvWQsvmi6Va8On3pWvqLHOt5aPUvFb/FeZTmphI4CPiQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.8.2.tgz",
|
||||
"integrity": "sha512-lCvikWOgaLOfqXGacEKSNeenvj0n3qR5QvZUOmPE2e1Eh8cMYSobxonCg9rqM6FSdTfbpqp9xwhSAOYfNqSW0g==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.8.2",
|
||||
"@prisma/engines-version": "6.8.0-43.2060c79ba17c6bb9f5823312b6f6b7f4a845738e",
|
||||
"@prisma/get-platform": "6.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.8.2.tgz",
|
||||
"integrity": "sha512-vXSxyUgX3vm1Q70QwzwkjeYfRryIvKno1SXbIqwSptKwqKzskINnDUcx85oX+ys6ooN2ATGSD0xN2UTfg6Zcow==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/focus": {
|
||||
"version": "3.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.3.tgz",
|
||||
"integrity": "sha512-rR5uZUMSY4xLHmpK/I8bP1V6vUNHFo33gTvrvNUsAKKqvMfa7R2nu5A6v97dr5g6tVH6xzpdkPsOJCWh90H2cw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/interactions": "^3.25.1",
|
||||
"@react-aria/utils": "^3.29.0",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/interactions": {
|
||||
"version": "3.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.1.tgz",
|
||||
"integrity": "sha512-ntLrlgqkmZupbbjekz3fE/n3eQH2vhncx8gUp0+N+GttKWevx7jos11JUBjnJwb1RSOPgRUFcrluOqBp0VgcfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.8",
|
||||
"@react-aria/utils": "^3.29.0",
|
||||
"@react-stately/flags": "^3.1.1",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/ssr": {
|
||||
"version": "3.9.8",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
|
||||
"integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/utils": {
|
||||
"version": "3.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.29.0.tgz",
|
||||
"integrity": "sha512-jSOrZimCuT1iKNVlhjIxDkAhgF7HSp3pqyT6qjg/ZoA0wfqCi/okmrMPiWSAKBnkgX93N8GYTLT3CIEO6WZe9Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@react-aria/ssr": "^3.9.8",
|
||||
"@react-stately/flags": "^3.1.1",
|
||||
"@react-stately/utils": "^3.10.6",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"clsx": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/flags": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz",
|
||||
"integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-stately/utils": {
|
||||
"version": "3.10.6",
|
||||
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz",
|
||||
"integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.1.tgz",
|
||||
"integrity": "sha512-KtM+cDf2CXoUX439rfEhbnEdAgFZX20UP2A35ypNIawR7/PFFPjQDWyA2EnClCcW/dLWJDEPX2U8+EJff8xqmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@ -1244,6 +1530,33 @@
|
||||
"tailwindcss": "4.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.8.tgz",
|
||||
"integrity": "sha512-meS2AanUg50f3FBSNoAdBSRAh8uS0ue01qm7zrw65KGJtiXB9QXfybqZwkh4uFpRv2iX/eu5tjcH5wqUpwYLPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.8",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.8.tgz",
|
||||
"integrity": "sha512-BT6w89Hqy7YKaWewYzmecXQzcJh6HTBbKYJIIkMaNU49DZ06LoTV3z32DWWEdUsgW6n1xTmwTLs4GtWrZC261w==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
||||
@ -2084,6 +2397,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
@ -2232,6 +2554,15 @@
|
||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@ -2284,6 +2615,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -3999,12 +4339,21 @@
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -4394,6 +4743,18 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||
@ -4602,6 +4963,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-auth": {
|
||||
"version": "4.24.11",
|
||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz",
|
||||
"integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.20.13",
|
||||
"@panva/hkdf": "^1.0.2",
|
||||
"cookie": "^0.7.0",
|
||||
"jose": "^4.15.5",
|
||||
"oauth": "^0.9.15",
|
||||
"openid-client": "^5.4.0",
|
||||
"preact": "^10.6.3",
|
||||
"preact-render-to-string": "^5.1.19",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@auth/core": "0.34.2",
|
||||
"next": "^12.2.5 || ^13 || ^14 || ^15",
|
||||
"nodemailer": "^6.6.5",
|
||||
"react": "^17.0.2 || ^18 || ^19",
|
||||
"react-dom": "^17.0.2 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@auth/core": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@ -4630,6 +5023,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@ -4640,6 +5039,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
|
||||
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@ -4753,6 +5161,30 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-token-hash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz",
|
||||
"integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^10.13.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^4.15.9",
|
||||
"lru-cache": "^6.0.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"oidc-token-hash": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@ -4919,6 +5351,28 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.26.6",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.26.6.tgz",
|
||||
"integrity": "sha512-5SRRBinwpwkaD+OqlBDeITlRgvd8I8QlxHJw9AxSdMNV6O+LodN9nUyYGpSF7sadHjs6RzeFShMexC6DbtWr9g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/preact-render-to-string": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz",
|
||||
"integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -4929,6 +5383,38 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
|
||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.8.2.tgz",
|
||||
"integrity": "sha512-JNricTXQxzDtRS7lCGGOB4g5DJ91eg3nozdubXze3LpcMl1oWwcFddrj++Up3jnRE6X/3gB/xz3V+ecBk/eEGA==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.8.2",
|
||||
"@prisma/engines": "6.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@ -5602,6 +6088,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz",
|
||||
@ -5637,6 +6129,16 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
|
||||
@ -5822,7 +6324,7 @@
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@ -5901,6 +6403,24 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -6017,14 +6537,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
|
||||
18
package.json
18
package.json
@ -9,19 +9,25 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.3",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.2"
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"prisma": "^6.8.2",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
91
prisma/migrations/20250519211949_init/migration.sql
Normal file
91
prisma/migrations/20250519211949_init/migration.sql
Normal file
@ -0,0 +1,91 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Account" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"provider" TEXT NOT NULL,
|
||||
"providerAccountId" TEXT NOT NULL,
|
||||
"refresh_token" TEXT,
|
||||
"access_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"token_type" TEXT,
|
||||
"scope" TEXT,
|
||||
"id_token" TEXT,
|
||||
"session_state" TEXT,
|
||||
|
||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Session" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionToken" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"email" TEXT,
|
||||
"emailVerified" TIMESTAMP(3),
|
||||
"image" TEXT,
|
||||
"password" TEXT,
|
||||
"role" TEXT DEFAULT 'user',
|
||||
"balance" DECIMAL(10,2) DEFAULT 0.00,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "VerificationToken" (
|
||||
"identifier" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"expires" TIMESTAMP(3) NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Transaction" (
|
||||
"id" TEXT NOT NULL,
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"userId" TEXT NOT NULL,
|
||||
"triggeredByAdminId" TEXT,
|
||||
|
||||
CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Transaction_triggeredByAdminId_idx" ON "Transaction"("triggeredByAdminId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_triggeredByAdminId_fkey" FOREIGN KEY ("triggeredByAdminId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;
|
||||
@ -0,0 +1,10 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "approvedByAdminId" TEXT,
|
||||
ADD COLUMN "isApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Transaction_userId_idx" ON "Transaction"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_approvedByAdminId_fkey" FOREIGN KEY ("approvedByAdminId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
88
prisma/schema.prisma
Normal file
88
prisma/schema.prisma
Normal file
@ -0,0 +1,88 @@
|
||||
// Datei: prisma/schema.prisma
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime? // Von NextAuth benötigt
|
||||
image String?
|
||||
password String? // Für CredentialsProvider
|
||||
role String? @default("user")
|
||||
balance Decimal? @default(0.00) @db.Decimal(10, 2)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Felder für den Freigabeprozess
|
||||
isApproved Boolean @default(false) // Ist der Benutzer vom Admin freigegeben?
|
||||
approvedAt DateTime? // Wann wurde der Benutzer freigegeben?
|
||||
approvedByAdminId String? // ID des Admins, der den Benutzer freigegeben hat
|
||||
approvedByAdmin User? @relation("AdminApprovals", fields: [approvedByAdminId], references: [id], onDelete: SetNull)
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
transactions Transaction[]
|
||||
triggeredTransactions Transaction[] @relation("AdminTransactions")
|
||||
approvedUsers User[] @relation("AdminApprovals") // Benutzer, die dieser Admin freigegeben hat
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(cuid())
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
type String
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
triggeredByAdminId String?
|
||||
triggeredByAdmin User? @relation("AdminTransactions", fields: [triggeredByAdminId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([triggeredByAdminId])
|
||||
@@index([userId]) // Index für userId in Transaction hinzugefügt
|
||||
}
|
||||
22
src/lib/prisma.ts
Normal file
22
src/lib/prisma.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
const globalForPrisma = global as typeof globalThis & { prisma?: PrismaClient };
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient();
|
||||
} else {
|
||||
if (!globalForPrisma.prisma) {
|
||||
globalForPrisma.prisma = new PrismaClient({
|
||||
log: ['query', 'info', 'warn', 'error'],
|
||||
});
|
||||
}
|
||||
prisma = globalForPrisma.prisma;
|
||||
}
|
||||
|
||||
export default prisma;
|
||||
@ -1,6 +1,11 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import type {AppProps} from "next/app";
|
||||
import {SessionProvider} from "next-auth/react";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
export default function App({Component, pageProps: {session, ...pageProps}}: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
536
src/pages/admin/index.tsx
Normal file
536
src/pages/admin/index.tsx
Normal file
@ -0,0 +1,536 @@
|
||||
// Datei: pages/admin/index.tsx
|
||||
import type { NextPage } from 'next';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
|
||||
// Typen für die Admin-Seite (Frontend)
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string | null;
|
||||
balance: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isApproved: boolean;
|
||||
approvedAt: string | null;
|
||||
_count?: {
|
||||
transactions?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AdminUsersApiResponse {
|
||||
users: AdminUser[];
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
interface AdminTransaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
amount: string;
|
||||
type: string;
|
||||
description: string | null;
|
||||
createdAt: string;
|
||||
triggeredByAdminId: string | null;
|
||||
triggeredByAdmin?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
interface AdminUserTransactionsApiResponse {
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
transactions: AdminTransaction[];
|
||||
totalTransactions: number;
|
||||
}
|
||||
|
||||
interface SetApprovalStatusApiResponse { // Typ für die Antwort der /set-approval-status API
|
||||
message: string;
|
||||
userId: string;
|
||||
isApproved: boolean;
|
||||
approvedAt: Date | null; // API liefert Date-Objekt, im Frontend ggf. als String behandeln
|
||||
}
|
||||
|
||||
|
||||
const AdminPanelPage: NextPage = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const [selectedUserForAdjustment, setSelectedUserForAdjustment] = useState<AdminUser | null>(null);
|
||||
const [adjustmentAmount, setAdjustmentAmount] = useState<string>('');
|
||||
const [adjustmentAction, setAdjustmentAction] = useState<'increase' | 'decrease'>('increase');
|
||||
const [adjustmentReason, setAdjustmentReason] = useState<string>('');
|
||||
const [isAdjustModalOpen, setIsAdjustModalOpen] = useState(false);
|
||||
|
||||
const [selectedUserForTransactions, setSelectedUserForTransactions] = useState<AdminUser | null>(null);
|
||||
const [userTransactions, setUserTransactions] = useState<AdminTransaction[]>([]);
|
||||
const [isLoadingUserTransactions, setIsLoadingUserTransactions] = useState(false);
|
||||
const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return;
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin');
|
||||
return;
|
||||
}
|
||||
if (session?.user?.role !== 'admin') {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
}, [status, session, router]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setIsLoadingUsers(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/users');
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || 'Fehler beim Laden der Benutzerliste');
|
||||
}
|
||||
const data: AdminUsersApiResponse = await res.json();
|
||||
const formattedUsers = data.users.map(user => ({
|
||||
...user,
|
||||
// approvedAt von der API kommt als ISO-String oder null
|
||||
approvedAt: user.approvedAt ? new Date(user.approvedAt).toLocaleString('de-DE') : null
|
||||
}));
|
||||
setUsers(formattedUsers);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unbekannter Fehler beim Laden der Benutzerliste ist aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Benutzerliste:", err);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetBalance = async (userId: string) => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
if (!confirm(`Möchtest du den Saldo für Benutzer ${userId} wirklich zurücksetzen?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/reset-balance`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Fehler beim Zurücksetzen des Saldos');
|
||||
}
|
||||
setSuccessMessage(data.message);
|
||||
await fetchUsers();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unbekannter Fehler beim Zurücksetzen des Saldos ist aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Saldo zurücksetzen:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const openAdjustModal = (user: AdminUser) => {
|
||||
setSelectedUserForAdjustment(user);
|
||||
setAdjustmentAmount('');
|
||||
setAdjustmentReason('');
|
||||
setAdjustmentAction('increase');
|
||||
setIsAdjustModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAdjustBalance = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUserForAdjustment) return;
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const amountNumber = parseFloat(adjustmentAmount);
|
||||
if (isNaN(amountNumber) || amountNumber <= 0) {
|
||||
setError("Bitte gib einen gültigen positiven Betrag ein.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${selectedUserForAdjustment.id}/adjust-balance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
amount: amountNumber,
|
||||
action: adjustmentAction,
|
||||
reason: adjustmentReason,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Fehler beim Anpassen des Saldos');
|
||||
}
|
||||
setSuccessMessage(data.message);
|
||||
setIsAdjustModalOpen(false);
|
||||
await fetchUsers();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unbekannter Fehler beim Anpassen des Saldos ist aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Saldo anpassen:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const openTransactionsModal = async (user: AdminUser) => {
|
||||
setSelectedUserForTransactions(user);
|
||||
setIsTransactionsModalOpen(true);
|
||||
setIsLoadingUserTransactions(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${user.id}/transactions`);
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || 'Fehler beim Laden der Transaktionen des Benutzers');
|
||||
}
|
||||
const data: AdminUserTransactionsApiResponse = await res.json();
|
||||
setUserTransactions(data.transactions);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unbekannter Fehler beim Laden der Transaktionen des Benutzers ist aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Transaktionen des Benutzers:", err);
|
||||
setUserTransactions([]);
|
||||
} finally {
|
||||
setIsLoadingUserTransactions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// NEU: Benutzer Freigabestatus umschalten
|
||||
const handleToggleApproval = async (userId: string, currentApprovalStatus: boolean) => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
const actionText = currentApprovalStatus ? "sperren (Freigabe widerrufen)" : "freigeben";
|
||||
if (!confirm(`Möchtest du den Benutzer ${userId} wirklich ${actionText}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/set-approval-status`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isApproved: !currentApprovalStatus }), // Den entgegengesetzten Status senden
|
||||
});
|
||||
const data: SetApprovalStatusApiResponse = await response.json(); // Typ für die Antwort verwenden
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || `Fehler beim ${actionText} des Benutzers.`);
|
||||
}
|
||||
setSuccessMessage(data.message);
|
||||
await fetchUsers(); // Benutzerliste neu laden, um den aktualisierten Status anzuzeigen
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError(`Ein unbekannter Fehler beim ${actionText} des Benutzers ist aufgetreten.`);
|
||||
}
|
||||
console.error(`Fehler Benutzer ${actionText}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (status === 'loading' || (status === 'authenticated' && session?.user?.role !== 'admin' && !isLoadingUsers) ) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Laden oder Zugriff wird geprüft...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Bitte als Admin anmelden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 sm:p-6 lg:p-8">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800">Admin Panel</h1>
|
||||
<p className="text-gray-600">Benutzerverwaltung</p>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="mr-4 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Zum Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md">{error}</div>}
|
||||
{successMessage && <div className="mb-4 p-3 bg-green-100 text-green-700 border border-green-300 rounded-md">{successMessage}</div>}
|
||||
|
||||
<section className="p-6 bg-white shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Benutzerübersicht</h2>
|
||||
{isLoadingUsers ? (
|
||||
<p className="text-gray-500">Benutzerliste wird geladen...</p>
|
||||
) : users.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">E-Mail</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Saldo</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rolle</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{user.name || 'N/A'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{user.email || 'N/A'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 font-medium">{user.balance ? `${user.balance} €` : '0.00 €'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">{user.role}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
|
||||
{user.isApproved ? (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||
Freigegeben
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||
Ausstehend/Gesperrt
|
||||
</span>
|
||||
)}
|
||||
{user.isApproved && user.approvedAt && (
|
||||
<p className="text-xs text-gray-500 mt-1">am {user.approvedAt}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
{user.isApproved ? (
|
||||
<button
|
||||
onClick={() => handleToggleApproval(user.id, user.isApproved)}
|
||||
className="text-yellow-600 hover:text-yellow-900"
|
||||
disabled={user.id === session?.user?.id && users.filter(u => u.role === 'admin' && u.isApproved).length <= 1 && user.role === 'admin'} // Verhindere Selbstsperrung des letzten Admins
|
||||
>
|
||||
Sperren
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => handleToggleApproval(user.id, user.isApproved)} className="text-green-600 hover:text-green-900">Freigeben</button>
|
||||
)}
|
||||
<button onClick={() => openAdjustModal(user)} className="text-indigo-600 hover:text-indigo-900">Anpassen</button>
|
||||
<button onClick={() => handleResetBalance(user.id)} className="text-red-600 hover:text-red-900">Reset</button>
|
||||
<button onClick={() => openTransactionsModal(user)} className="text-blue-600 hover:text-blue-900">Verlauf</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Keine Benutzer gefunden.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modal für Saldo anpassen */}
|
||||
<Transition appear show={isAdjustModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsAdjustModalOpen(false)}>
|
||||
<Transition.Child
|
||||
as="div"
|
||||
className="fixed inset-0"
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as="div"
|
||||
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Saldo anpassen für {selectedUserForAdjustment?.name || selectedUserForAdjustment?.email}
|
||||
</Dialog.Title>
|
||||
<form onSubmit={handleAdjustBalance} className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="adjustmentAmount" className="block text-sm font-medium text-gray-700">Betrag (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
name="adjustmentAmount"
|
||||
id="adjustmentAmount"
|
||||
step="0.01"
|
||||
value={adjustmentAmount}
|
||||
onChange={(e) => setAdjustmentAmount(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-400 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adjustmentAction" className="block text-sm font-medium text-gray-700">Aktion</label>
|
||||
<select
|
||||
id="adjustmentAction"
|
||||
name="adjustmentAction"
|
||||
value={adjustmentAction}
|
||||
onChange={(e) => setAdjustmentAction(e.target.value as 'increase' | 'decrease')}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border border-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md text-gray-900"
|
||||
>
|
||||
<option value="increase">Erhöhen</option>
|
||||
<option value="decrease">Verringern</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adjustmentReason" className="block text-sm font-medium text-gray-700">Grund (optional)</label>
|
||||
<textarea
|
||||
id="adjustmentReason"
|
||||
name="adjustmentReason"
|
||||
rows={3}
|
||||
value={adjustmentReason}
|
||||
onChange={(e) => setAdjustmentReason(e.target.value)}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-400 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-gray-200 px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||
onClick={() => setIsAdjustModalOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
|
||||
>
|
||||
Saldo anpassen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
{/* Modal für Transaktionsverlauf */}
|
||||
<Transition appear show={isTransactionsModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsTransactionsModalOpen(false)}>
|
||||
<Transition.Child
|
||||
as="div"
|
||||
className="fixed inset-0"
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as="div"
|
||||
className="w-full max-w-3xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all"
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900">
|
||||
Transaktionsverlauf für {selectedUserForTransactions?.name || selectedUserForTransactions?.email}
|
||||
</Dialog.Title>
|
||||
<div className="mt-4">
|
||||
{isLoadingUserTransactions ? (
|
||||
<p>Lade Transaktionen...</p>
|
||||
) : userTransactions.length > 0 ? (
|
||||
<div className="overflow-x-auto max-h-96">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Betrag</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Admin</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{userTransactions.map(tx => (
|
||||
<tr key={tx.id}>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-gray-700">{new Date(tx.createdAt).toLocaleString('de-DE')}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-gray-700">{tx.type}</td>
|
||||
<td className={`px-4 py-2 whitespace-nowrap text-sm font-medium ${parseFloat(tx.amount) >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{parseFloat(tx.amount).toFixed(2)} €
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-gray-700">{tx.triggeredByAdmin?.name || (tx.triggeredByAdminId ? 'Unbekannt' : '-')}</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap text-sm text-gray-700">{tx.description || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-700">Keine Transaktionen für diesen Benutzer gefunden.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
onClick={() => setIsTransactionsModalOpen(false)}
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPanelPage;
|
||||
103
src/pages/api/admin/users.ts
Normal file
103
src/pages/api/admin/users.ts
Normal file
@ -0,0 +1,103 @@
|
||||
// Datei: pages/api/admin/users.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../auth/[...nextauth]'; // Pfad zu deiner authOptions anpassen
|
||||
import prisma from '../../../lib/prisma'; // Pfad zu deinem Prisma Client Singleton anpassen
|
||||
// User-Typ von Prisma wird hier nicht mehr direkt für ApiAdminUser benötigt,
|
||||
// da wir den Typ explizit definieren.
|
||||
|
||||
// Definiere den Typ für die Benutzerdaten, wie sie von dieser API gesendet werden.
|
||||
// Dieser Typ enthält genau die Felder, die wir auswählen und transformieren.
|
||||
type ApiAdminUser = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
role: string | null;
|
||||
image: string | null;
|
||||
balance: string | null; // Saldo als String
|
||||
createdAt: string; // Datum als ISO-String
|
||||
updatedAt: string; // Datum als ISO-String
|
||||
isApproved: boolean; // Freigabestatus
|
||||
approvedAt: string | null; // Freigabedatum als ISO-String oder null
|
||||
_count?: { // _count ist optional und enthält die optionale Anzahl der Transaktionen
|
||||
transactions?: number;
|
||||
};
|
||||
};
|
||||
|
||||
interface AdminUsersApiResponse {
|
||||
users: ApiAdminUser[];
|
||||
totalUsers: number;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AdminUsersApiResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Nur für Administratoren.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const usersFromDb = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
balance: true,
|
||||
image: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isApproved: true,
|
||||
approvedAt: true,
|
||||
_count: {
|
||||
select: { transactions: true }
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// Konvertiere Decimal-Saldo und DateTime-Objekte in Strings für die JSON-Antwort
|
||||
// und stelle sicher, dass die Struktur dem ApiAdminUser-Typ entspricht.
|
||||
const apiAdminUsers: ApiAdminUser[] = usersFromDb.map(user => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
image: user.image,
|
||||
balance: user.balance ? user.balance.toFixed(2) : null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
isApproved: user.isApproved,
|
||||
approvedAt: user.approvedAt ? user.approvedAt.toISOString() : null,
|
||||
_count: { // Stelle sicher, dass _count immer ein Objekt ist, auch wenn transactions 0 ist
|
||||
transactions: user._count?.transactions ?? 0 // Fallback auf 0, falls undefined
|
||||
}
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
users: apiAdminUsers,
|
||||
totalUsers: apiAdminUsers.length,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Fehler beim Abrufen der Benutzerliste für Admin:', error);
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: `Interner Serverfehler: ${error.message}` });
|
||||
}
|
||||
return res.status(500).json({ message: 'Ein unbekannter interner Serverfehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
146
src/pages/api/admin/users/[userId]/adjust-balance.ts
Normal file
146
src/pages/api/admin/users/[userId]/adjust-balance.ts
Normal file
@ -0,0 +1,146 @@
|
||||
// Datei: pages/api/admin/users/[userId]/adjust-balance.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../../../auth/[...nextauth]'; // Pfad anpassen
|
||||
import prisma from '../../../../../lib/prisma'; // Pfad anpassen
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; // Für spezifische Prisma-Fehler
|
||||
|
||||
interface AdjustBalanceRequestBody {
|
||||
amount: number; // Der Betrag der Anpassung (muss positiv sein)
|
||||
action: 'increase' | 'decrease'; // Art der Anpassung
|
||||
reason?: string; // Optionaler Grund für die Anpassung
|
||||
}
|
||||
|
||||
interface SuccessResponse {
|
||||
message: string;
|
||||
oldBalance: string;
|
||||
adjustmentAmount: string;
|
||||
newBalance: string;
|
||||
userId: string;
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SuccessResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Nur für Administratoren.' });
|
||||
}
|
||||
|
||||
const adminUserId = session.user.id;
|
||||
|
||||
const { userId: targetUserId } = req.query;
|
||||
if (typeof targetUserId !== 'string') {
|
||||
return res.status(400).json({ message: 'Ungültige Benutzer-ID in der URL.' });
|
||||
}
|
||||
|
||||
const { amount, action, reason } = req.body as AdjustBalanceRequestBody;
|
||||
|
||||
if (typeof amount !== 'number' || amount <= 0) {
|
||||
return res.status(400).json({ message: 'Ungültiger Betrag. Der Betrag muss eine positive Zahl sein.' });
|
||||
}
|
||||
if (action !== 'increase' && action !== 'decrease') {
|
||||
return res.status(400).json({ message: 'Ungültige Aktion. Erlaubt sind "increase" oder "decrease".' });
|
||||
}
|
||||
|
||||
const adjustmentAmountDecimal = new Decimal(amount);
|
||||
|
||||
try {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const targetUser = await tx.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { balance: true, id: true },
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new Error('Zielbenutzer nicht gefunden.');
|
||||
}
|
||||
const currentBalance = targetUser.balance ?? new Decimal(0);
|
||||
|
||||
// Admin-Namen für die Beschreibung abrufen
|
||||
const adminExecutingAction = await tx.user.findUnique({
|
||||
where: { id: adminUserId },
|
||||
select: { name: true }
|
||||
});
|
||||
const adminName = adminExecutingAction?.name || adminUserId; // Fallback auf ID
|
||||
|
||||
let newBalance: Decimal;
|
||||
let transactionAmount: Decimal;
|
||||
let transactionType: string;
|
||||
let description: string;
|
||||
|
||||
if (action === 'increase') {
|
||||
newBalance = currentBalance.plus(adjustmentAmountDecimal);
|
||||
transactionAmount = adjustmentAmountDecimal;
|
||||
transactionType = 'admin_correction_increase';
|
||||
description = `Saldo durch Admin '${adminName}' um ${adjustmentAmountDecimal.toFixed(2)}€ erhöht. Grund: ${reason || 'Kein Grund angegeben'}.`; // Admin-Name verwendet
|
||||
} else { // action === 'decrease'
|
||||
newBalance = currentBalance.minus(adjustmentAmountDecimal);
|
||||
transactionAmount = adjustmentAmountDecimal.negated();
|
||||
transactionType = 'admin_correction_decrease';
|
||||
description = `Saldo durch Admin '${adminName}' um ${adjustmentAmountDecimal.toFixed(2)}€ verringert. Grund: ${reason || 'Kein Grund angegeben'}.`; // Admin-Name verwendet
|
||||
}
|
||||
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: targetUserId },
|
||||
data: {
|
||||
balance: newBalance,
|
||||
},
|
||||
select: { id: true, balance: true }
|
||||
});
|
||||
|
||||
const newTransaction = await tx.transaction.create({
|
||||
data: {
|
||||
userId: targetUserId,
|
||||
amount: transactionAmount,
|
||||
type: transactionType,
|
||||
description: description,
|
||||
triggeredByAdminId: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return { updatedUser, newTransaction, oldBalance: currentBalance, adjustmentAmount: adjustmentAmountDecimal };
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Saldo für Benutzer ${targetUserId} erfolgreich angepasst.`,
|
||||
oldBalance: result.oldBalance.toFixed(2),
|
||||
adjustmentAmount: result.adjustmentAmount.toFixed(2),
|
||||
newBalance: result.updatedUser.balance?.toFixed(2),
|
||||
userId: result.updatedUser.id,
|
||||
transactionId: result.newTransaction.id,
|
||||
});
|
||||
|
||||
} catch (error: unknown) { // Geändert von 'any' zu 'unknown'
|
||||
console.error(`Fehler beim Anpassen des Saldos für Benutzer ${targetUserId}:`, error);
|
||||
|
||||
// Typüberprüfung für das Fehlerobjekt
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Zielbenutzer nicht gefunden.') {
|
||||
return res.status(404).json({ message: error.message });
|
||||
}
|
||||
// Spezifische Prisma-Fehler prüfen
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2025') { // Record to update/delete not found
|
||||
return res.status(404).json({ message: 'Zielbenutzer nicht gefunden (Prisma P2025).' });
|
||||
}
|
||||
}
|
||||
// Fallback für andere Error-Instanzen
|
||||
return res.status(500).json({ message: `Interner Serverfehler: ${error.message}` });
|
||||
}
|
||||
// Fallback für Fehler, die keine Error-Instanzen sind
|
||||
return res.status(500).json({ message: 'Ein unbekannter interner Serverfehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
110
src/pages/api/admin/users/[userId]/reset-balance.ts
Normal file
110
src/pages/api/admin/users/[userId]/reset-balance.ts
Normal file
@ -0,0 +1,110 @@
|
||||
// Datei: pages/api/admin/users/[userId]/reset-balance.ts
|
||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||
import {getServerSession} from 'next-auth/next';
|
||||
import {authOptions} from '../../../auth/[...nextauth]'; // Pfad anpassen
|
||||
import prisma from '../../../../../lib/prisma'; // Pfad anpassen
|
||||
import {Decimal, PrismaClientKnownRequestError} from '@prisma/client/runtime/library';
|
||||
|
||||
interface SuccessResponse {
|
||||
message: string;
|
||||
newBalance: string; // Neuer Saldo als String
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SuccessResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({message: `Method ${req.method} Not Allowed`});
|
||||
}
|
||||
|
||||
// Admin-Session abrufen und validieren
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({message: 'Zugriff verweigert. Nur für Administratoren.'});
|
||||
}
|
||||
|
||||
const adminUserId = session.user.id; // ID des Admins, der die Aktion ausführt
|
||||
|
||||
// Zielbenutzer-ID aus der URL extrahieren
|
||||
const {userId: targetUserId} = req.query;
|
||||
|
||||
if (typeof targetUserId !== 'string') {
|
||||
return res.status(400).json({message: 'Ungültige Benutzer-ID in der URL.'});
|
||||
}
|
||||
|
||||
try {
|
||||
// Prisma-Transaktion für atomare Operationen
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Aktuellen Saldo des Zielbenutzers abrufen, um den Transaktionsbetrag zu bestimmen
|
||||
const targetUser = await tx.user.findUnique({
|
||||
where: {id: targetUserId},
|
||||
select: {balance: true, id: true},
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new Error('Zielbenutzer nicht gefunden.');
|
||||
}
|
||||
|
||||
const currentBalance = targetUser.balance ?? new Decimal(0);
|
||||
const transactionAmount = currentBalance.negated();
|
||||
|
||||
// Admin-Namen für die Beschreibung abrufen
|
||||
const adminExecutingAction = await tx.user.findUnique({
|
||||
where: {id: adminUserId},
|
||||
select: {name: true}
|
||||
});
|
||||
const adminName = adminExecutingAction?.name || adminUserId; // Fallback auf ID, falls kein Name vorhanden
|
||||
|
||||
// 2. Saldo des Zielbenutzers auf 0 setzen
|
||||
const updatedUser = await tx.user.update({
|
||||
where: {id: targetUserId},
|
||||
data: {
|
||||
balance: new Decimal(0),
|
||||
},
|
||||
select: {balance: true, id: true}
|
||||
});
|
||||
|
||||
// 3. Transaktion erstellen, um das Zurücksetzen zu protokollieren
|
||||
await tx.transaction.create({
|
||||
data: {
|
||||
userId: targetUserId,
|
||||
amount: transactionAmount,
|
||||
type: 'admin_reset',
|
||||
description: `Saldo durch Admin '${adminName}' zurückgesetzt. Ursprünglicher Saldo: ${currentBalance.toFixed(2)}€.`, // Admin-Name verwendet
|
||||
triggeredByAdminId: adminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Saldo für Benutzer ${targetUserId} erfolgreich auf 0.00€ zurückgesetzt.`,
|
||||
newBalance: result.balance?.toFixed(2),
|
||||
userId: result.id
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error(`Fehler beim Zurücksetzen des Saldos für Benutzer ${targetUserId}:`, error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Zielbenutzer nicht gefunden.') {
|
||||
return res.status(404).json({message: error.message});
|
||||
}
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({message: 'Zielbenutzer nicht gefunden (Prisma P2025).'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(500).json({message: 'Interner Serverfehler beim Zurücksetzen des Saldos.'});
|
||||
}
|
||||
}
|
||||
126
src/pages/api/admin/users/[userId]/set-approval-status.ts
Normal file
126
src/pages/api/admin/users/[userId]/set-approval-status.ts
Normal file
@ -0,0 +1,126 @@
|
||||
// Datei: pages/api/admin/users/[userId]/set-approval-status.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../../../auth/[...nextauth]'; // Pfad anpassen
|
||||
import prisma from '../../../../../lib/prisma'; // Pfad anpassen
|
||||
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
|
||||
|
||||
interface SetApprovalStatusRequestBody {
|
||||
isApproved: boolean; // Der neue gewünschte Freigabestatus
|
||||
}
|
||||
|
||||
interface SuccessResponse {
|
||||
message: string;
|
||||
userId: string;
|
||||
isApproved: boolean;
|
||||
approvedAt: Date | null;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SuccessResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
// Admin-Session abrufen und validieren
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Nur für Administratoren.' });
|
||||
}
|
||||
|
||||
const adminUserId = session.user.id;
|
||||
|
||||
// Zielbenutzer-ID aus der URL extrahieren
|
||||
const { userId: targetUserId } = req.query;
|
||||
if (typeof targetUserId === 'string' && targetUserId === adminUserId && req.body.isApproved === false) {
|
||||
return res.status(400).json({ message: 'Administratoren können sich nicht selbst sperren.' });
|
||||
}
|
||||
|
||||
|
||||
if (typeof targetUserId !== 'string') {
|
||||
return res.status(400).json({ message: 'Ungültige Benutzer-ID in der URL.' });
|
||||
}
|
||||
|
||||
// Neuen Freigabestatus aus dem Body extrahieren
|
||||
const { isApproved } = req.body as SetApprovalStatusRequestBody;
|
||||
if (typeof isApproved !== 'boolean') {
|
||||
return res.status(400).json({ message: 'Ungültiger Wert für isApproved im Request Body. Muss true oder false sein.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Überprüfen, ob der Zielbenutzer existiert
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true }, // Rolle prüfen, um Selbstsperrung von Admins zu verhindern
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: 'Zielbenutzer nicht gefunden.' });
|
||||
}
|
||||
|
||||
// Verhindere, dass ein Admin sich selbst die Freigabe entzieht, wenn er der einzige Admin ist
|
||||
// oder generell Admins nicht gesperrt werden sollen (optional, je nach Anforderung)
|
||||
if (targetUser.id === adminUserId && !isApproved && targetUser.role === 'admin') {
|
||||
// Zusätzliche Prüfung: Ist dies der letzte Admin?
|
||||
const adminCount = await prisma.user.count({
|
||||
where: { role: 'admin', isApproved: true }
|
||||
});
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ message: 'Der letzte Administrator kann nicht gesperrt werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let dataToUpdate;
|
||||
if (isApproved) {
|
||||
dataToUpdate = {
|
||||
isApproved: true,
|
||||
approvedAt: new Date(),
|
||||
approvedByAdminId: adminUserId,
|
||||
};
|
||||
} else {
|
||||
dataToUpdate = {
|
||||
isApproved: false,
|
||||
approvedAt: null,
|
||||
approvedByAdminId: null, // Oder den Admin speichern, der die Freigabe widerrufen hat
|
||||
};
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: targetUserId },
|
||||
data: dataToUpdate,
|
||||
select: {
|
||||
id: true,
|
||||
isApproved: true,
|
||||
approvedAt: true,
|
||||
}
|
||||
});
|
||||
|
||||
const actionMessage = isApproved ? "freigegeben" : "gesperrt (Freigabe widerrufen)";
|
||||
return res.status(200).json({
|
||||
message: `Benutzer ${targetUserId} erfolgreich ${actionMessage}.`,
|
||||
userId: updatedUser.id,
|
||||
isApproved: updatedUser.isApproved,
|
||||
approvedAt: updatedUser.approvedAt,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error(`Fehler beim Aktualisieren des Freigabestatus für Benutzer ${targetUserId}:`, error);
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({ message: 'Zielbenutzer nicht gefunden (Prisma P2025).' });
|
||||
}
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: `Interner Serverfehler: ${error.message}` });
|
||||
}
|
||||
return res.status(500).json({ message: 'Ein unbekannter interner Serverfehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
103
src/pages/api/admin/users/[userId]/transactions.ts
Normal file
103
src/pages/api/admin/users/[userId]/transactions.ts
Normal file
@ -0,0 +1,103 @@
|
||||
// Datei: pages/api/admin/users/[userId]/transactions.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../../../auth/[...nextauth]'; // Pfad anpassen
|
||||
import prisma from '../../../../../lib/prisma'; // Pfad anpassen
|
||||
import { Transaction, User } from '@prisma/client'; // Importiere relevante Typen
|
||||
|
||||
// Erweitere den Transaction-Typ, um optional den auslösenden Admin einzuschließen
|
||||
type TransactionWithAdmin = Transaction & {
|
||||
triggeredByAdmin?: { // Optional, da nicht jede Transaktion von einem Admin ausgelöst wird
|
||||
id: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
interface AdminUserTransactionsResponse {
|
||||
user: { // Informationen über den Benutzer, dessen Transaktionen angezeigt werden
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
transactions: TransactionWithAdmin[];
|
||||
totalTransactions: number;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<AdminUserTransactionsResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
// Admin-Session abrufen und validieren
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Nur für Administratoren.' });
|
||||
}
|
||||
|
||||
// Zielbenutzer-ID aus der URL extrahieren
|
||||
const { userId: targetUserId } = req.query;
|
||||
if (typeof targetUserId !== 'string') {
|
||||
return res.status(400).json({ message: 'Ungültige Benutzer-ID in der URL.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Zuerst den Zielbenutzer finden, um sicherzustellen, dass er existiert
|
||||
// und um seine Details in der Antwort mitzugeben.
|
||||
const targetUser = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ message: 'Zielbenutzer nicht gefunden.' });
|
||||
}
|
||||
|
||||
// Transaktionen für den Zielbenutzer abrufen
|
||||
// Inklusive der Information über den auslösenden Admin, falls vorhanden
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
userId: targetUserId,
|
||||
},
|
||||
include: {
|
||||
triggeredByAdmin: { // Lade die zugehörigen Admin-Benutzerdaten
|
||||
select: {
|
||||
id: true,
|
||||
name: true, // Nur ID und Name des Admins
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc', // Neueste Transaktionen zuerst
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
user: {
|
||||
id: targetUser.id,
|
||||
name: targetUser.name,
|
||||
email: targetUser.email,
|
||||
},
|
||||
transactions: transactions,
|
||||
totalTransactions: transactions.length,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Fehler beim Abrufen des Transaktionsverlaufs für Benutzer ${targetUserId}:`, error);
|
||||
if (error.code === 'P2025') { // Prisma: Record to query not found (kann bei Relationen auftreten)
|
||||
return res.status(404).json({ message: 'Fehler beim Laden der zugehörigen Daten (Prisma P2025).' });
|
||||
}
|
||||
return res.status(500).json({ message: 'Interner Serverfehler beim Abrufen des Transaktionsverlaufs.' });
|
||||
}
|
||||
}
|
||||
102
src/pages/api/auth/[...nextauth].ts
Normal file
102
src/pages/api/auth/[...nextauth].ts
Normal file
@ -0,0 +1,102 @@
|
||||
// Datei: pages/api/auth/[...nextauth].ts
|
||||
|
||||
import NextAuth, { AuthOptions } from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter"
|
||||
import prisma from "../../../lib/prisma" // Pfad zu deinem Prisma Client Singleton anpassen
|
||||
import bcrypt from "bcryptjs"
|
||||
|
||||
// authOptions explizit als AuthOptions typisieren
|
||||
export const authOptions: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma), // Der Adapter wird weiterhin für User/Account Management genutzt
|
||||
session: {
|
||||
strategy: "jwt", // Beibehaltung der JWT-Strategie
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "E-Mail", type: "email", placeholder: "user@example.com" },
|
||||
password: { label: "Passwort", type: "password" }
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
if (!credentials?.email || !credentials.password) {
|
||||
throw new Error("E-Mail und Passwort sind erforderlich.");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email.toLowerCase() } // E-Mail normalisieren für den Abgleich
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
// Benutzer nicht gefunden oder hat kein Passwort gesetzt (z.B. nur OAuth)
|
||||
throw new Error("Benutzer nicht gefunden oder Passwort nicht gesetzt.");
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
throw new Error("E-Mail oder Passwort ist falsch.");
|
||||
}
|
||||
|
||||
// NEU: Überprüfung des Freigabestatus
|
||||
if (!user.isApproved) {
|
||||
throw new Error("Dein Konto wurde noch nicht von einem Administrator freigegeben.");
|
||||
}
|
||||
|
||||
// Stelle sicher, dass das zurückgegebene User-Objekt mit deiner
|
||||
// erweiterten User-Definition in next-auth.d.ts übereinstimmt.
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
image: user.image,
|
||||
isApproved: user.isApproved, // isApproved zum User-Objekt hinzufügen, das an jwt Callback geht
|
||||
};
|
||||
}
|
||||
}),
|
||||
// Füge hier weitere Provider hinzu, z.B. Google:
|
||||
// GoogleProvider({
|
||||
// clientId: process.env.GOOGLE_CLIENT_ID,
|
||||
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
// }),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
// Das 'user'-Objekt ist hier das, was vom 'authorize'-Callback zurückgegeben wird.
|
||||
// Es ist nur beim ersten Aufruf nach der Anmeldung (oder Token-Erstellung) vorhanden.
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = user.role;
|
||||
token.isApproved = user.isApproved; // isApproved zum JWT hinzufügen
|
||||
// Standard-Claims wie name, email, picture werden oft automatisch von NextAuth hinzugefügt,
|
||||
// wenn sie im User-Objekt vorhanden sind.
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// Das 'token'-Argument enthält die entschlüsselten Daten aus dem JWT.
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = token.role as string | undefined; // oder string | null, je nach Definition
|
||||
session.user.isApproved = token.isApproved as boolean | undefined; // isApproved zur Session hinzufügen
|
||||
// session.user.name = token.name; // Wird oft schon durch DefaultSession abgedeckt
|
||||
// session.user.email = token.email; // Wird oft schon durch DefaultSession abgedeckt
|
||||
// session.user.image = token.picture; // Wird oft schon durch DefaultSession abgedeckt (picture ist der JWT Claim für image)
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: '/auth/signin',
|
||||
error: '/auth/error', // Deine benutzerdefinierte Fehlerseite
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET, // Sollte in .env.local definiert sein
|
||||
// debug: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
export default NextAuth(authOptions);
|
||||
86
src/pages/api/auth/signup.ts
Normal file
86
src/pages/api/auth/signup.ts
Normal file
@ -0,0 +1,86 @@
|
||||
// Datei: pages/api/auth/signup.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import prisma from '../../../lib/prisma'; // Pfad zu deinem Prisma Client Singleton anpassen
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client'; // Für spezifische Prisma-Fehlercodes
|
||||
|
||||
interface SignUpResponse {
|
||||
message: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
errors?: { field: string; message: string }[];
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SignUpResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
// --- Validierung der Eingaben ---
|
||||
const validationErrors: { field: string; message: string }[] = [];
|
||||
|
||||
if (!name || typeof name !== 'string' || name.trim().length < 2) {
|
||||
validationErrors.push({ field: 'name', message: 'Name ist erforderlich und muss mindestens 2 Zeichen lang sein.' });
|
||||
}
|
||||
if (!email || typeof email !== 'string' || !/^\S+@\S+\.\S+$/.test(email)) {
|
||||
validationErrors.push({ field: 'email', message: 'Gültige E-Mail-Adresse ist erforderlich.' });
|
||||
}
|
||||
if (!password || typeof password !== 'string' || password.length < 6) {
|
||||
validationErrors.push({ field: 'password', message: 'Passwort ist erforderlich und muss mindestens 6 Zeichen lang sein.' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ message: 'Validierungsfehler.', errors: validationErrors });
|
||||
}
|
||||
// --- Ende Validierung ---
|
||||
|
||||
try {
|
||||
// Prüfen, ob Benutzer bereits existiert
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase() }, // E-Mail normalisieren für den Check
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ message: 'Ein Benutzer mit dieser E-Mail-Adresse existiert bereits.' });
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const hashedPassword = await bcrypt.hash(password, 10); // 10 ist die Anzahl der Salt-Runden
|
||||
|
||||
// Neuen Benutzer erstellen
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
email: email.toLowerCase(), // E-Mail normalisieren für die Speicherung
|
||||
password: hashedPassword,
|
||||
role: 'user', // Standardrolle für neue Benutzer
|
||||
// balance wird durch den Default-Wert im Schema gesetzt (0.00)
|
||||
// createdAt und updatedAt werden automatisch durch Prisma gesetzt
|
||||
},
|
||||
select: { // Nur die ID des neuen Benutzers zurückgeben
|
||||
id: true,
|
||||
}
|
||||
});
|
||||
|
||||
return res.status(201).json({ message: 'Benutzer erfolgreich registriert.', userId: newUser.id });
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Registrierungsfehler:', error);
|
||||
// Spezifische Prisma-Fehler abfangen, z.B. Unique Constraint Violation (obwohl wir vorher prüfen)
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') { // Unique constraint failed
|
||||
return res.status(409).json({ message: 'Diese E-Mail-Adresse wird bereits verwendet (DB-Constraint).' });
|
||||
}
|
||||
}
|
||||
return res.status(500).json({ message: 'Interner Serverfehler bei der Registrierung.' });
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
type Data = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export default function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>,
|
||||
) {
|
||||
res.status(200).json({ name: "John Doe" });
|
||||
}
|
||||
65
src/pages/api/user/balance.ts
Normal file
65
src/pages/api/user/balance.ts
Normal file
@ -0,0 +1,65 @@
|
||||
// Datei: pages/api/user/balance.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../auth/[...nextauth]'; // Pfad zu deiner authOptions anpassen
|
||||
import prisma from '../../../lib/prisma'; // Pfad zu deinem Prisma Client Singleton anpassen
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
interface BalanceResponse {
|
||||
balance: Decimal | null;
|
||||
lastUpdated?: Date | null; // Optional: Wann wurde der Saldo zuletzt aktualisiert (aus dem User-Modell, falls du so ein Feld hättest)
|
||||
userName?: string | null;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<BalanceResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
// Benutzer-Session abrufen, um sicherzustellen, dass der Benutzer angemeldet ist
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return res.status(401).json({ message: 'Nicht autorisiert. Bitte anmelden.' });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
balance: true,
|
||||
name: true, // Optional: Name des Benutzers mitsenden
|
||||
// Du könntest hier auch ein Feld 'updatedAt' aus dem User-Modell auswählen,
|
||||
// wenn du wissen möchtest, wann der Benutzerdatensatz zuletzt geändert wurde.
|
||||
// Dies ist aber nicht unbedingt der Zeitpunkt der letzten Saldo-Änderung.
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'Benutzer nicht gefunden.' });
|
||||
}
|
||||
|
||||
// Das 'balance'-Feld kann null sein, wenn es nie gesetzt wurde,
|
||||
// obwohl wir einen Standardwert im Schema haben. Sicher ist sicher.
|
||||
return res.status(200).json({
|
||||
balance: user.balance ?? new Decimal(0), // Fallback auf 0, falls balance null ist
|
||||
userName: user.name,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Saldos:', error);
|
||||
return res.status(500).json({ message: 'Interner Serverfehler beim Abrufen des Saldos.' });
|
||||
}
|
||||
}
|
||||
102
src/pages/api/user/increase-balance.ts
Normal file
102
src/pages/api/user/increase-balance.ts
Normal file
@ -0,0 +1,102 @@
|
||||
// Datei: pages/api/user/increase-balance.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../auth/[...nextauth]'; // Pfad zu deiner authOptions anpassen
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import prisma from "@/lib/prisma"; // Für korrekte Decimal-Handhabung
|
||||
|
||||
// Definiere die erwartete Struktur des Request-Body
|
||||
interface IncreaseBalanceRequestBody {
|
||||
amount: number; // Der Betrag, um den erhöht werden soll (z.B. 0.10, 0.50, 1.00)
|
||||
}
|
||||
|
||||
// Definiere die erlaubten Beträge und ihre zugehörigen Transaktionstypen
|
||||
const ALLOWED_AMOUNTS: Record<number, string> = {
|
||||
0.10: "deposit_0.10",
|
||||
0.20: "deposit_0.20",
|
||||
0.50: "deposit_0.50",
|
||||
1.00: "deposit_1.00",
|
||||
2.00: "deposit_2.00",
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
// Benutzer-Session abrufen, um sicherzustellen, dass der Benutzer angemeldet ist
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return res.status(401).json({ message: 'Nicht autorisiert. Bitte anmelden.' });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const { amount } = req.body as IncreaseBalanceRequestBody;
|
||||
|
||||
// Validierung des Betrags
|
||||
if (typeof amount !== 'number' || !ALLOWED_AMOUNTS[amount]) {
|
||||
return res.status(400).json({
|
||||
message: 'Ungültiger oder nicht erlaubter Betrag.',
|
||||
allowedAmounts: Object.keys(ALLOWED_AMOUNTS).map(Number),
|
||||
});
|
||||
}
|
||||
|
||||
const increaseAmountDecimal = new Decimal(amount);
|
||||
const transactionType = ALLOWED_AMOUNTS[amount];
|
||||
|
||||
// Prisma-Transaktion verwenden, um sicherzustellen, dass beide Operationen atomar sind
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Benutzer-Saldo aktualisieren
|
||||
const updatedUser = await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
balance: {
|
||||
increment: increaseAmountDecimal,
|
||||
},
|
||||
},
|
||||
select: { // Nur die benötigten Felder auswählen
|
||||
id: true,
|
||||
balance: true,
|
||||
name: true,
|
||||
}
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
throw new Error('Benutzer nicht gefunden oder Update fehlgeschlagen.');
|
||||
}
|
||||
|
||||
// 2. Transaktion erstellen
|
||||
const newTransaction = await tx.transaction.create({
|
||||
data: {
|
||||
amount: increaseAmountDecimal,
|
||||
type: transactionType,
|
||||
userId: userId,
|
||||
// description: `Einzahlung von ${amount.toFixed(2)}€`, // Optionale Beschreibung
|
||||
},
|
||||
});
|
||||
|
||||
return { updatedUser, newTransaction };
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: `Saldo erfolgreich um ${amount.toFixed(2)}€ erhöht.`,
|
||||
newBalance: result.updatedUser.balance,
|
||||
transactionId: result.newTransaction.id,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erhöhen des Saldos:', error);
|
||||
// Spezifischere Fehlerbehandlung basierend auf dem Fehlerobjekt
|
||||
if (error instanceof Error && error.message.includes('Benutzer nicht gefunden')) {
|
||||
return res.status(404).json({ message: 'Benutzer nicht gefunden.' });
|
||||
}
|
||||
return res.status(500).json({ message: 'Interner Serverfehler beim Erhöhen des Saldos.' });
|
||||
}
|
||||
}
|
||||
85
src/pages/api/user/transactions.ts
Normal file
85
src/pages/api/user/transactions.ts
Normal file
@ -0,0 +1,85 @@
|
||||
// Datei: pages/api/user/transactions.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { authOptions } from '../auth/[...nextauth]'; // Pfad zu deiner authOptions anpassen
|
||||
import prisma from '../../../lib/prisma'; // Pfad zu deinem Prisma Client Singleton anpassen
|
||||
|
||||
// Definieren einen neuen Typ für die Transaktion in der API-Antwort
|
||||
// bei dem 'amount' ein String ist.
|
||||
interface ApiTransaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
amount: string; // Betrag als String
|
||||
type: string;
|
||||
description: string | null;
|
||||
createdAt: string; // Datum als ISO-String
|
||||
triggeredByAdminId: string | null;
|
||||
triggeredByAdmin?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface TransactionsResponse {
|
||||
transactions: ApiTransaction[];
|
||||
totalTransactions: number;
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<TransactionsResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
// Benutzer-Session abrufen
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
|
||||
if (!session || !session.user || !session.user.id) {
|
||||
return res.status(401).json({ message: 'Nicht autorisiert. Bitte anmelden.' });
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
try {
|
||||
const dbTransactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
include: {
|
||||
triggeredByAdmin: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
// Konvertiere PrismaTransaction in ApiTransaction (Decimal zu String, Date zu ISOString)
|
||||
const apiTransactions: ApiTransaction[] = dbTransactions.map(tx => ({
|
||||
...tx,
|
||||
amount: tx.amount.toFixed(2), // Decimal zu String mit 2 Nachkommastellen
|
||||
createdAt: tx.createdAt.toISOString(), // Date zu ISO String
|
||||
// triggeredByAdmin bleibt wie es ist (oder wird entsprechend angepasst, falls nötig)
|
||||
}));
|
||||
|
||||
return res.status(200).json({
|
||||
transactions: apiTransactions,
|
||||
totalTransactions: apiTransactions.length,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Transaktionsverlaufs:', error);
|
||||
return res.status(500).json({ message: 'Interner Serverfehler beim Abrufen des Transaktionsverlaufs.' });
|
||||
}
|
||||
}
|
||||
88
src/pages/auth/error.tsx
Normal file
88
src/pages/auth/error.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
// Datei: pages/auth/error.tsx
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import type { NextPage } from 'next';
|
||||
|
||||
const AuthErrorPage: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { error } = router.query as { error?: string }; // Fehler-Parameter aus der URL holen und typisieren
|
||||
|
||||
let errorMessage: string = 'Ein unbekannter Fehler ist aufgetreten.';
|
||||
let errorSuggestion: string = 'Bitte versuche es später erneut oder kontaktiere den Support.';
|
||||
|
||||
// Hier kannst du spezifische Fehlermeldungen basierend auf dem `error` Code von NextAuth.js hinzufügen
|
||||
// Siehe: https://next-auth.js.org/configuration/pages#error-page
|
||||
if (error) {
|
||||
switch (error) {
|
||||
case 'CredentialsSignin':
|
||||
errorMessage = 'Anmeldung fehlgeschlagen.';
|
||||
errorSuggestion = 'Bitte überprüfe deine E-Mail und dein Passwort und versuche es erneut.';
|
||||
break;
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
errorMessage = 'Fehler bei der OAuth-Anmeldung.';
|
||||
errorSuggestion = 'Es gab ein Problem mit dem externen Anmeldeanbieter. Bitte versuche es erneut.';
|
||||
break;
|
||||
case 'EmailSignin':
|
||||
errorMessage = 'Fehler beim Senden der Anmelde-E-Mail.';
|
||||
errorSuggestion = 'Bitte stelle sicher, dass deine E-Mail-Adresse korrekt ist und versuche es erneut.';
|
||||
break;
|
||||
// Füge hier weitere spezifische Fehlerfälle hinzu, falls nötig
|
||||
default:
|
||||
// Zeige den generischen Fehlercode an, wenn er nicht spezifisch behandelt wird
|
||||
errorMessage = `Fehler: ${error}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-red-600">
|
||||
Authentifizierungsfehler
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10">
|
||||
<div className="text-center">
|
||||
<p className="text-md font-medium text-gray-700">{errorMessage}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{errorSuggestion}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Zurück zur Anmeldung
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Oder zur Startseite
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// getServerSideProps ist hier nicht unbedingt nötig, da der Fehlercode über die URL kommt.
|
||||
// Wenn du es dennoch verwenden möchtest (z.B. für serverseitiges Logging):
|
||||
// import type { GetServerSideProps } from 'next';
|
||||
//
|
||||
// export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
// const { error } = context.query as { error?: string };
|
||||
// // Hier könntest du serverseitiges Logging des Fehlers durchführen
|
||||
// if (error) {
|
||||
// console.error("Auth Error on Page (SSR):", error);
|
||||
// }
|
||||
// return { props: {} }; // Keine spezifischen Props benötigt, da der Fehler aus der Query gelesen wird
|
||||
// };
|
||||
|
||||
export default AuthErrorPage;
|
||||
226
src/pages/auth/signin.tsx
Normal file
226
src/pages/auth/signin.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
// Datei: pages/auth/signin.tsx
|
||||
import type { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next'; // GetServerSidePropsContext hinzugefügt
|
||||
import { getProviders, signIn, useSession, getCsrfToken, getSession } from 'next-auth/react'; // getSession hinzugefügt
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ClientSafeProvider, LiteralUnion } from 'next-auth/react';
|
||||
import type { ParsedUrlQuery } from 'querystring'; // Für GetServerSidePropsContext
|
||||
import type { PreviewData } from 'next/dist/types';
|
||||
import {BuiltInProviderType} from "next-auth/providers/index"; // Für GetServerSidePropsContext
|
||||
|
||||
interface SignInProps {
|
||||
providers: Record<LiteralUnion<BuiltInProviderType, string>, ClientSafeProvider> | null;
|
||||
csrfToken: string | undefined;
|
||||
}
|
||||
|
||||
const SignInPage: NextPage<SignInProps> = ({ providers, csrfToken }) => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Fehler aus der URL-Query extrahieren (von NextAuth gesetzt)
|
||||
useEffect(() => {
|
||||
if (router.query.error) {
|
||||
switch (router.query.error) {
|
||||
case 'CredentialsSignin':
|
||||
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.');
|
||||
break;
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
setError('Fehler bei der OAuth-Anmeldung. Bitte versuche es erneut.');
|
||||
break;
|
||||
case 'EmailSignin':
|
||||
setError('Fehler beim Senden der Anmelde-E-Mail.');
|
||||
break;
|
||||
default:
|
||||
setError(`Ein unbekannter Anmeldefehler ist aufgetreten: ${router.query.error}`);
|
||||
break;
|
||||
}
|
||||
// Entferne den Fehler aus der URL, um ihn nicht erneut anzuzeigen, wenn der Benutzer interagiert
|
||||
const newPath = router.pathname;
|
||||
const queryWithoutError = { ...router.query };
|
||||
delete queryWithoutError.error;
|
||||
router.replace({ pathname: newPath, query: queryWithoutError }, undefined, { shallow: true });
|
||||
}
|
||||
}, [router.query.error, router]);
|
||||
|
||||
// Weiterleitung, wenn bereits authentifiziert
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
const callbackUrl = router.query.callbackUrl as string || '/dashboard';
|
||||
router.push(callbackUrl);
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null); // Reset error before new attempt
|
||||
|
||||
const result = await signIn('credentials', {
|
||||
redirect: false, // Wir behandeln die Weiterleitung manuell oder warten auf den useEffect
|
||||
email: email,
|
||||
password: password,
|
||||
// callbackUrl: router.query.callbackUrl as string || '/dashboard' // Optional: hier schon setzen
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (result?.error) {
|
||||
// Der Fehler wird auch über router.query.error gesetzt, aber wir können ihn hier direkt setzen
|
||||
switch (result.error) {
|
||||
case 'CredentialsSignin':
|
||||
setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.');
|
||||
break;
|
||||
default:
|
||||
setError(result.error || 'Ein unbekannter Fehler ist aufgetreten.');
|
||||
}
|
||||
} else if (result?.ok && !result.error) {
|
||||
// Erfolgreich, Weiterleitung wird durch useEffect oben oder durch callbackUrl in signIn gehandhabt
|
||||
// router.push(router.query.callbackUrl as string || '/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading' || status === 'authenticated') {
|
||||
// Zeige Ladezustand, während auf Session-Status oder Weiterleitung gewartet wird
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
{/* Optional: Logo hier einfügen */}
|
||||
{/* <img className="mx-auto h-12 w-auto" src="/logo.svg" alt="Logo" /> */}
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Anmelden bei deiner Strichliste
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* CSRF Token ist nicht explizit für das manuelle signIn mit Credentials nötig,
|
||||
aber NextAuth fügt es hinzu, wenn man die Standard-Submit-Action verwendet.
|
||||
Da wir signIn('credentials', ...) verwenden, ist es implizit gehandhabt.
|
||||
Wenn du action="/api/auth/callback/credentials" verwenden würdest, wäre es nötig:
|
||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} />
|
||||
*/}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="deine@email.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Passwort
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Anmelden...' : 'Anmelden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Buttons für andere Provider (z.B. Google, GitHub), falls konfiguriert */}
|
||||
{providers && Object.values(providers).map((provider) => {
|
||||
if (provider.id === 'credentials') return null; // Credentials Provider ist das Formular oben
|
||||
return (
|
||||
<div key={provider.name} className="mt-3">
|
||||
<button
|
||||
onClick={() => signIn(provider.id, { callbackUrl: router.query.callbackUrl as string || '/dashboard' })}
|
||||
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
{/* Hier könntest du Provider-spezifische Icons hinzufügen */}
|
||||
Mit {provider.name} anmelden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<p className="text-gray-600">
|
||||
Noch kein Konto?{' '}
|
||||
<a href="/auth/signup" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Registrieren
|
||||
</a>
|
||||
{/* TODO: Registrierungsseite erstellen */}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<SignInProps, ParsedUrlQuery, PreviewData> = async (
|
||||
context: GetServerSidePropsContext<ParsedUrlQuery, PreviewData>
|
||||
) => {
|
||||
// Wenn der Benutzer bereits angemeldet ist, leite ihn direkt zum Dashboard weiter.
|
||||
// Dies verhindert ein kurzes Aufblitzen der Anmeldeseite.
|
||||
const session = await getSession(context); // getSession importiert
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: (context.query.callbackUrl as string) || '/dashboard',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const providers = await getProviders();
|
||||
const csrfToken = await getCsrfToken(context);
|
||||
return {
|
||||
props: {
|
||||
providers: providers, // Korrigiert: providers direkt zuweisen (kann null sein)
|
||||
csrfToken: csrfToken ?? undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default SignInPage;
|
||||
247
src/pages/auth/signup.tsx
Normal file
247
src/pages/auth/signup.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
// Datei: pages/auth/signup.tsx
|
||||
import type { NextPage, GetServerSideProps } from 'next';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getSession } from 'next-auth/react';
|
||||
|
||||
type SignUpPageProps = object // Keine spezifischen Props vom Server benötigt
|
||||
|
||||
const SignUpPage: NextPage<SignUpPageProps> = () => {
|
||||
const { status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{ field: string; message: string }[]>([]);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Weiterleitung, wenn bereits authentifiziert
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
const getFieldError = (fieldName: string): string | undefined => {
|
||||
return fieldErrors.find(err => err.field === fieldName)?.message;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setFieldErrors([]);
|
||||
setSuccessMessage(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (data.errors) {
|
||||
setFieldErrors(data.errors);
|
||||
} else {
|
||||
setError(data.message || 'Registrierung fehlgeschlagen.');
|
||||
}
|
||||
throw new Error(data.message || 'Registrierung fehlgeschlagen.');
|
||||
}
|
||||
|
||||
setSuccessMessage(data.message + ' Du wirst in Kürze zur Anmeldung weitergeleitet.');
|
||||
// Kurze Verzögerung vor der Weiterleitung, damit der Benutzer die Nachricht lesen kann
|
||||
setTimeout(() => {
|
||||
router.push('/auth/signin');
|
||||
}, 3000);
|
||||
|
||||
} catch (err: unknown) {
|
||||
console.error('Registrierungsfehler (Client):', err);
|
||||
// Der Fehler wird bereits im if (!response.ok) Block gesetzt,
|
||||
// außer es ist ein Netzwerkfehler o.ä.
|
||||
|
||||
if (typeof err === 'string') {
|
||||
setError(err);
|
||||
} else if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unerwarteter Fehler ist aufgetreten.');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'loading' || status === 'authenticated') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Neues Konto erstellen
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl ring-1 ring-gray-900/10 sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMessage && (
|
||||
<div className="mb-4 p-3 bg-green-100 text-green-700 border border-green-300 rounded-md text-sm">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`appearance-none text-black block w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none sm:text-sm ${getFieldError('name') ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'}`}
|
||||
/>
|
||||
{getFieldError('name') && <p className="mt-1 text-xs text-red-600">{getFieldError('name')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
E-Mail-Adresse
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`appearance-none text-black block w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none sm:text-sm ${getFieldError('email') ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'}`}
|
||||
/>
|
||||
{getFieldError('email') && <p className="mt-1 text-xs text-red-600">{getFieldError('email')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Passwort (min. 6 Zeichen)
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`appearance-none text-black block w-full px-3 py-2 border rounded-md shadow-sm placeholder-gray-400 focus:outline-none sm:text-sm ${getFieldError('password') ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : 'border-gray-300 focus:ring-indigo-500 focus:border-indigo-500'}`}
|
||||
/>
|
||||
{getFieldError('password') && <p className="mt-1 text-xs text-red-600">{getFieldError('password')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Registrieren...' : 'Konto erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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-white text-gray-500">Oder</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Bereits ein Konto?{' '}
|
||||
<Link href="/auth/signin" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Hier anmelden
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Serverseitige Prüfung, ob der Benutzer bereits angemeldet ist.
|
||||
// Wenn ja, direkt zum Dashboard weiterleiten.
|
||||
export const getServerSideProps: GetServerSideProps<SignUpPageProps> = async (context) => {
|
||||
const session = await getSession(context);
|
||||
if (session) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/dashboard',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
};
|
||||
|
||||
export default SignUpPage;
|
||||
281
src/pages/dashboard.tsx
Normal file
281
src/pages/dashboard.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
// Datei: pages/dashboard.tsx
|
||||
import type { NextPage } from 'next';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// WICHTIG: Stelle sicher, dass hier KEINE Importe von '@prisma/client' stehen.
|
||||
// Auch nicht für Typen wie 'Transaction'. Definiere Typen für das Frontend manuell.
|
||||
|
||||
// Typ für Transaktionen, wie sie vom Backend (API) erwartet werden
|
||||
// und im Frontend verwendet werden. Alle Felder sind primitive Typen oder Strings.
|
||||
interface DashboardTransaction {
|
||||
id: string;
|
||||
userId: string;
|
||||
amount: string; // Betrag ist ein String (z.B. "10.50")
|
||||
type: string; // Behalten wir im Datentyp, entfernen es nur aus der Anzeige
|
||||
description: string | null;
|
||||
createdAt: string; // Datum als ISO-String (z.B. "2023-05-15T10:30:00.000Z")
|
||||
triggeredByAdminId: string | null;
|
||||
triggeredByAdmin?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// Typ für die Antwort von /api/user/balance
|
||||
interface BalanceResponse {
|
||||
balance: string | null; // Saldo kommt als String von der API
|
||||
userName?: string | null;
|
||||
}
|
||||
|
||||
// Typ für die Antwort von /api/user/transactions
|
||||
interface TransactionsApiResponse {
|
||||
transactions: DashboardTransaction[]; // Verwende den oben definierten Typ
|
||||
totalTransactions: number;
|
||||
}
|
||||
|
||||
// Typ für die Antwort von /api/user/increase-balance
|
||||
interface IncreaseBalanceApiResponse {
|
||||
message: string;
|
||||
newBalance: string; // Neuer Saldo als String
|
||||
transactionId: string;
|
||||
}
|
||||
|
||||
const DashboardPage: NextPage = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const [balance, setBalance] = useState<string | null>(null);
|
||||
const [userName, setUserName] = useState<string | null>(null);
|
||||
const [transactions, setTransactions] = useState<DashboardTransaction[]>([]);
|
||||
const [isLoadingBalance, setIsLoadingBalance] = useState(true);
|
||||
const [isLoadingTransactions, setIsLoadingTransactions] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
// Weiterleitung, wenn nicht authentifiziert
|
||||
useEffect(() => {
|
||||
if (status === 'unauthenticated') {
|
||||
router.push('/auth/signin');
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Saldo abrufen
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
setIsLoadingBalance(true);
|
||||
fetch('/api/user/balance')
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || 'Fehler beim Laden des Saldos');
|
||||
}
|
||||
return res.json() as Promise<BalanceResponse>;
|
||||
})
|
||||
.then((data) => {
|
||||
setBalance(data.balance);
|
||||
setUserName(data.userName || session?.user?.name || null);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Laden des Saldos aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Saldo:", err);
|
||||
})
|
||||
.finally(() => setIsLoadingBalance(false));
|
||||
}
|
||||
}, [status, session]);
|
||||
|
||||
// Transaktionsverlauf abrufen
|
||||
const fetchTransactions = async () => {
|
||||
if (status === 'authenticated') {
|
||||
setIsLoadingTransactions(true);
|
||||
setError(null); // Fehler zurücksetzen vor neuem Abruf
|
||||
try {
|
||||
const res = await fetch('/api/user/transactions');
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || 'Fehler beim Laden der Transaktionen');
|
||||
}
|
||||
const data: TransactionsApiResponse = await res.json();
|
||||
setTransactions(data.transactions);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Laden der Transaktionen aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Transaktionen:", err);
|
||||
} finally {
|
||||
setIsLoadingTransactions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
fetchTransactions();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
|
||||
// Saldo erhöhen (oder "Ausgabe hinzufügen")
|
||||
const handleIncreaseBalance = async (amount: number) => {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const response = await fetch('/api/user/increase-balance', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount }), // API erwartet 'amount'
|
||||
});
|
||||
const data: IncreaseBalanceApiResponse = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Fehler beim Hinzufügen der Ausgabe');
|
||||
}
|
||||
setBalance(data.newBalance);
|
||||
setSuccessMessage(data.message);
|
||||
await fetchTransactions(); // Transaktionsliste neu laden
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Hinzufügen der Ausgabe aufgetreten.');
|
||||
}
|
||||
console.error("Fehler beim Hinzufügen:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const predefinedAmounts = [0.10, 0.20, 0.50, 1.00, 2.00];
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Laden...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated' || !session?.user) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<p className="text-lg text-gray-600">Bitte anmelden, um das Dashboard zu sehen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-4 sm:p-6 lg:p-8">
|
||||
<header className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800">Willkommen, {userName || session.user.name}!</h1>
|
||||
<p className="text-gray-600">Deine Strichliste</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4"> {/* Container für Buttons im Header */}
|
||||
{session?.user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Admin Panel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/auth/signin' })}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md">{error}</div>}
|
||||
{successMessage && <div className="mb-4 p-3 bg-green-100 text-green-700 border border-green-300 rounded-md">{successMessage}</div>}
|
||||
|
||||
{/* Saldo Sektion */}
|
||||
<section className="mb-8 p-6 bg-white shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Dein aktueller Saldo</h2>
|
||||
{isLoadingBalance ? (
|
||||
<p className="text-gray-500">Saldo wird geladen...</p>
|
||||
) : (
|
||||
<p className="text-4xl font-bold text-indigo-600">
|
||||
{balance !== null ? `${parseFloat(balance).toFixed(2)} €` : 'N/A'}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Ausgaben hinzufügen Sektion */}
|
||||
<section className="mb-8 p-6 bg-white shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Ausgaben hinzufügen</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
|
||||
{predefinedAmounts.map((amount) => (
|
||||
<button
|
||||
key={amount}
|
||||
onClick={() => handleIncreaseBalance(amount)}
|
||||
className="px-4 py-3 bg-indigo-600 text-white font-medium rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150"
|
||||
>
|
||||
+ {amount.toFixed(2)} €
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Transaktionsverlauf Sektion */}
|
||||
<section className="p-6 bg-white shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Dein Transaktionsverlauf</h2>
|
||||
{isLoadingTransactions ? (
|
||||
<p className="text-gray-500">Transaktionen werden geladen...</p>
|
||||
) : transactions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Datum</th>
|
||||
{/* <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Typ</th> */} {/* Entfernt */}
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Betrag</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{transactions.map((tx) => (
|
||||
<tr key={tx.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{new Date(tx.createdAt).toLocaleString('de-DE')}</td>
|
||||
{/* <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{tx.type}</td> */} {/* Entfernt */}
|
||||
<td className={`px-6 py-4 whitespace-nowrap text-sm font-medium ${parseFloat(tx.amount) >= 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{parseFloat(tx.amount).toFixed(2)} €
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{tx.description || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Keine Transaktionen vorhanden.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Link zum Admin Panel wurde in den Header verschoben */}
|
||||
{/*
|
||||
{session?.user?.role === 'admin' && (
|
||||
<section className="mt-8 p-6 bg-white shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 mb-4">Admin Bereich</h2>
|
||||
<button
|
||||
onClick={() => router.push('/admin')}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
Zum Admin Panel
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@ -1,115 +1,74 @@
|
||||
import Image from "next/image";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
// Datei: pages/index.tsx
|
||||
import type { NextPage, GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { getSession } from 'next-auth/react'; // Für serverseitige Session-Prüfung
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
// Für diese Seite sind keine spezifischen Props vom Server notwendig,
|
||||
// da sie hauptsächlich für die Weiterleitung zuständig ist.
|
||||
type IndexPageProps = object
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
const IndexPage: NextPage<IndexPageProps> = () => {
|
||||
const {status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
export default function Home() {
|
||||
useEffect(() => {
|
||||
// Client-seitige Weiterleitung, falls getServerSideProps nicht greift oder
|
||||
// der Status sich nach dem initialen Laden ändert.
|
||||
if (status === 'loading') {
|
||||
// Nichts tun, während die Session geladen wird
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 'authenticated') {
|
||||
router.replace('/dashboard'); // Zum Dashboard, wenn angemeldet
|
||||
} else if (status === 'unauthenticated') {
|
||||
router.replace('/auth/signin'); // Zur Anmeldeseite, wenn nicht angemeldet
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Zeige eine Ladeanzeige, während die Weiterleitung vorbereitet wird.
|
||||
// Dies wird normalerweise nur sehr kurz oder gar nicht sichtbar sein,
|
||||
// wenn getServerSideProps die Weiterleitung bereits serverseitig durchführt.
|
||||
return (
|
||||
<div
|
||||
className={`${geistSans.className} ${geistMono.className} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
|
||||
>
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/pages/index.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 text-center p-4">
|
||||
<svg className="animate-spin h-12 w-12 text-indigo-600 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<h1 className="text-2xl font-semibold text-gray-700 mb-2">Strichliste wird geladen...</h1>
|
||||
<p className="text-gray-500">Du wirst in Kürze weitergeleitet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerSideProps = async (
|
||||
context: GetServerSidePropsContext
|
||||
): Promise<GetServerSidePropsResult<IndexPageProps>> => {
|
||||
const session = await getSession(context);
|
||||
|
||||
if (session) {
|
||||
// Wenn der Benutzer angemeldet ist, leite serverseitig zum Dashboard weiter.
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/dashboard',
|
||||
permanent: false, // Nicht permanent, da der Anmeldestatus sich ändern kann
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Wenn der Benutzer nicht angemeldet ist, leite serverseitig zur Anmeldeseite weiter.
|
||||
return {
|
||||
redirect: {
|
||||
destination: '/auth/signin',
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Dieser Teil wird theoretisch nie erreicht, da immer eine Weiterleitung erfolgt.
|
||||
// Aber um TypeScript zufriedenzustellen, geben wir leere Props zurück, falls doch.
|
||||
// return { props: {} };
|
||||
};
|
||||
|
||||
export default IndexPage;
|
||||
|
||||
12
src/types/global.d.ts
vendored
Normal file
12
src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
// global.d.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface Global {
|
||||
prisma: PrismaClient | undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
41
src/types/next-auth.d.ts
vendored
Normal file
41
src/types/next-auth.d.ts
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
// Datei: next-auth.d.ts
|
||||
// oder z.B. types/next-auth.d.ts
|
||||
|
||||
import { DefaultSession, DefaultUser } from "next-auth";
|
||||
import { DefaultJWT } from "next-auth/jwt";
|
||||
|
||||
// Erweitere das User-Objekt, das von deinem Adapter oder authorize-Callback zurückgegeben wird
|
||||
declare module "next-auth" {
|
||||
/**
|
||||
* Das User-Objekt, wie es von deinem Adapter oder authorize-Callback zurückgegeben wird.
|
||||
* Füge hier alle benutzerdefinierten Eigenschaften hinzu.
|
||||
*/
|
||||
interface User extends DefaultUser {
|
||||
id: string; // Die ID des Benutzers, typischerweise aus deiner Datenbank
|
||||
role?: string | null; // Deine benutzerdefinierte Rolle
|
||||
isApproved?: boolean; // Status der Admin-Freigabe
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Session-Objekt, wie es vom `session` Callback zurückgegeben und
|
||||
* auf dem Client über `useSession` oder `getSession` verwendet wird.
|
||||
*/
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string; // ID des Benutzers
|
||||
role?: string | null; // Die Rolle des Benutzers
|
||||
isApproved?: boolean; // Status der Admin-Freigabe
|
||||
// Behalte die Standardeigenschaften von DefaultSession["user"] bei
|
||||
} & DefaultSession["user"]; // Erbt name, email, image von DefaultSession["user"]
|
||||
}
|
||||
}
|
||||
|
||||
// Erweitere das JWT-Objekt, falls du die JWT-Strategie verwendest und dem Token benutzerdefinierte Eigenschaften hinzufügst
|
||||
declare module "next-auth/jwt" {
|
||||
/** Returned by the `jwt` callback and `getToken`, when using JWT sessions */
|
||||
interface JWT extends DefaultJWT {
|
||||
id?: string;
|
||||
role?: string | null;
|
||||
isApproved?: boolean; // Status der Admin-Freigabe
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user