initial commit

This commit is contained in:
Elias Bennour 2025-05-20 01:06:10 +02:00
parent 96e8a993fc
commit 3141ba60f4
28 changed files with 3297 additions and 142 deletions

536
package-lock.json generated
View File

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

View File

@ -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"
}
}

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

View File

@ -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;

View File

@ -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;

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

View File

@ -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
View 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;

View 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.' });
}
}

View 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.' });
}
}

View 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.'});
}
}

View 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.' });
}
}

View 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.' });
}
}

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

View 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.' });
}
}

View File

@ -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" });
}

View 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.' });
}
}

View 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.' });
}
}

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

View File

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