diff --git a/.env.example b/.env.example index 435407d..82574c6 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,5 @@ DATABASE_URL=postgres://steakhouse:steakhouse@localhost:5432/steakhouse +BETTER_AUTH_SECRET=replace-with-a-long-random-string +BETTER_AUTH_URL=http://localhost:3000 +STAFF_SEED_EMAIL=admin@steakhaus-meppen.de +STAFF_SEED_PASSWORD=replace-with-a-strong-password diff --git a/package-lock.json b/package-lock.json index 69dddca..081cbc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "dependencies": { "@tanstack/react-router": "latest", "@tanstack/react-start": "latest", + "better-auth": "^1.6.29", "drizzle-orm": "^0.45.2", "postgres": "^3.4.9", "react": "^19.2.0", @@ -293,11 +294,144 @@ "node": ">=6.9.0" } }, + "node_modules/@better-auth/core": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.29.tgz", + "integrity": "sha512-hkUxePo548G0Y247had36TcfsH7E+cofcTc2UivMAbjkdLPKn1ZUk6Pjkc/kvk9xTbWYCUDX6JA9emJc0Gnufg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.4.0", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.29.tgz", + "integrity": "sha512-IdvrqHnnRNKqz6KOSII3rZNiTzv1pmVmx5xGRGZdVm8W3OYi98an5kIovao/b3GZh2nHGTzdeTfMSSZNCyGXBg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/@better-auth/kysely-adapter": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.29.tgz", + "integrity": "sha512-n87hHC/miSaUaKdnQygz+2wlj2KI2rsGazDPaKF0wbH8i87um9HPDWyz3VdbkqPN79e0qbVfMR9pY7GZQm/X8w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/@better-auth/memory-adapter": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.29.tgz", + "integrity": "sha512-F1yLUbNTc/pikEaTlgWOgYlT2RDSi2dV1ysWxacRmQhYtLGYAun1JgU7TDdcEjts+wrLcm+JzRzt5F+ZgNfz9A==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/@better-auth/mongo-adapter": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.29.tgz", + "integrity": "sha512-gyOpfBBCpe2wDnv9CY2T0jirC4oTqk3i5svWYBGkGTAFrKsfJA516NMOs001PiHsEDf/JttZapQv5dkoPD0x2w==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/@better-auth/prisma-adapter": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.29.tgz", + "integrity": "sha512-W5lr8AYXwa68qhS7PEpEsisF7ASs9rWJyczoZr8+vYypPBmGMq1Yf8bYJ9ig95zj1wdaQGim2ZSYs8hxZeaKVQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/@better-auth/telemetry": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.29.tgz", + "integrity": "sha512-zW1GRIBR/sn83siG+xK4ll/gzfTRxVh6rcpzzjYkR2W9/DkDMwEotl/Mz8OW0Gr2KpuhEkfwiVGH9zp/cyK4Pg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.29", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@esbuild-kit/core-utils": { @@ -305,7 +439,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.18.20", @@ -690,7 +824,7 @@ "version": "0.18.20", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -729,7 +863,7 @@ "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", "deprecated": "Merged into tsx: https://tsx.hirok.io", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", @@ -1223,6 +1357,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/ciphers": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", + "integrity": "sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", + "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@oozcitak/dom": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", @@ -1271,6 +1429,15 @@ "node": ">=20.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -1526,6 +1693,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tanstack/history": { "version": "1.162.1", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.1.tgz", @@ -2102,6 +2275,146 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.29", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.29.tgz", + "integrity": "sha512-7i2kOry+Jb1mRUR14kCDVS23GCKUGWxayZRXULbNz/6nFLPT6XSuGJVrwo/d+Qfq/iubA0/4Acw2/eg/tPCCnA==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.29", + "@better-auth/drizzle-adapter": "1.6.29", + "@better-auth/kysely-adapter": "1.6.29", + "@better-auth/memory-adapter": "1.6.29", + "@better-auth/mongo-adapter": "1.6.29", + "@better-auth/prisma-adapter": "1.6.29", + "@better-auth/telemetry": "1.6.29", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.4.0", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-call": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.4.0.tgz", + "integrity": "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.5.0", + "@better-fetch/fetch": "^1.3.1", + "rou3": "^0.9.1", + "set-cookie-parser": "^3.1.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-call/node_modules/@better-auth/utils": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/better-call/node_modules/rou3": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.9.2.tgz", + "integrity": "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==", + "license": "MIT" + }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -2139,7 +2452,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/caniuse-lite": { @@ -2248,6 +2561,12 @@ } } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2283,7 +2602,7 @@ "version": "0.31.10", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", @@ -2741,7 +3060,7 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3035,7 +3354,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -3097,6 +3416,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", + "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", @@ -3149,6 +3477,15 @@ "node": ">=6" } }, + "node_modules/kysely": { + "version": "0.29.5", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz", + "integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3441,6 +3778,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.5.0.tgz", + "integrity": "sha512-E0SrU7uLV/k4E6YbBIpQNDnRj00vgE5wlutM1sYVSAXDtHQosiRCGS9eKMKyiGg4ChXfDiJmTB+NCR3yXr6UAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -3579,7 +3931,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -3660,6 +4012,12 @@ "seroval": "^1.0" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -3683,7 +4041,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -3694,7 +4052,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3760,7 +4118,7 @@ "version": "4.23.12", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" diff --git a/package.json b/package.json index 19730c2..77e8eea 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "@tanstack/react-router": "latest", "@tanstack/react-start": "latest", + "better-auth": "^1.6.29", "drizzle-orm": "^0.45.2", "postgres": "^3.4.9", "react": "^19.2.0", diff --git a/public/DMC_001.png b/public/DMC_001.png deleted file mode 100644 index a0fad5e..0000000 Binary files a/public/DMC_001.png and /dev/null differ diff --git a/src/db/schema.ts b/src/db/schema.ts index 130996f..1253e74 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core' +import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core' export const restaurantTables = pgTable('restaurant_tables', { id: serial('id').primaryKey(), @@ -22,6 +22,63 @@ export const reservations = pgTable( (table) => [unique().on(table.tableId, table.reservationTime)], ) +// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database +// `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin. +export const user = pgTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull(), + image: text('image'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + role: text('role'), + banned: boolean('banned'), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires'), +}) + +export const session = pgTable('session', { + id: text('id').primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + impersonatedBy: text('impersonated_by'), +}) + +export const account = pgTable('account', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const verification = pgTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), +}) + export type RestaurantTable = typeof restaurantTables.$inferSelect export type Reservation = typeof reservations.$inferSelect export type NewReservation = typeof reservations.$inferInsert diff --git a/src/db/seed.ts b/src/db/seed.ts index 515600c..5c07873 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,22 +1,74 @@ import 'dotenv/config' +import { randomUUID } from 'node:crypto' +import { hashPassword } from 'better-auth/crypto' +import { eq } from 'drizzle-orm' + import { db } from './index' -import { restaurantTables } from './schema' +import { account, restaurantTables, user } from './schema' async function seed() { - const tables = await db - .insert(restaurantTables) - .values([ - { name: 'Tisch 1', seats: 2 }, - { name: 'Tisch 2', seats: 2 }, - { name: 'Tisch 3', seats: 4 }, - { name: 'Tisch 4', seats: 4 }, - { name: 'Tisch 5', seats: 6 }, - { name: 'Tisch 6', seats: 8 }, - ]) - .returning() + const [existingTable] = await db.select().from(restaurantTables).limit(1) + + if (existingTable) { + console.log('Skipped table seed (restaurant_tables is not empty).') + } else { + const tables = await db + .insert(restaurantTables) + .values([ + { name: 'Tisch 1', seats: 2 }, + { name: 'Tisch 2', seats: 2 }, + { name: 'Tisch 3', seats: 4 }, + { name: 'Tisch 4', seats: 4 }, + { name: 'Tisch 5', seats: 6 }, + { name: 'Tisch 6', seats: 8 }, + ]) + .returning() + + console.log(`Seeded ${tables.length} tables.`) + } + + const staffEmail = process.env.STAFF_SEED_EMAIL + const staffPassword = process.env.STAFF_SEED_PASSWORD + + if (staffEmail && staffPassword) { + const email = staffEmail.toLowerCase() + const [existing] = await db.select().from(user).where(eq(user.email, email)) + + if (existing) { + console.log(`Staff account already exists: ${email}`) + } else { + const staffUserId = randomUUID() + const now = new Date() + + await db.insert(user).values({ + id: staffUserId, + name: 'Admin', + email, + emailVerified: true, + role: 'admin', + banned: false, + createdAt: now, + updatedAt: now, + }) + + // Mirrors what Better Auth's sign-up endpoint stores for an email/password account. + await db.insert(account).values({ + id: randomUUID(), + userId: staffUserId, + accountId: staffUserId, + providerId: 'credential', + password: await hashPassword(staffPassword), + createdAt: now, + updatedAt: now, + }) + + console.log(`Seeded staff account: ${email}`) + } + } else { + console.log('Skipped staff account seed (STAFF_SEED_EMAIL / STAFF_SEED_PASSWORD not set).') + } - console.log(`Seeded ${tables.length} tables.`) process.exit(0) } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..7fd03a9 --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,6 @@ +import { createAuthClient } from 'better-auth/react' +import { adminClient } from 'better-auth/client/plugins' + +export const authClient = createAuthClient({ + plugins: [adminClient()], +}) diff --git a/src/lib/auth-instance.ts b/src/lib/auth-instance.ts new file mode 100644 index 0000000..fb251d6 --- /dev/null +++ b/src/lib/auth-instance.ts @@ -0,0 +1,43 @@ +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { admin } from 'better-auth/plugins/admin' +import { tanstackStartCookies } from 'better-auth/tanstack-start' + +import { db } from '#/db' +import * as schema from '#/db/schema' + +function createAuth() { + const secret = process.env.BETTER_AUTH_SECRET + + if (!secret) { + throw new Error('BETTER_AUTH_SECRET environment variable is not set') + } + + return betterAuth({ + secret, + database: drizzleAdapter(db, { + provider: 'pg', + schema, + }), + emailAndPassword: { + enabled: true, + // Staff accounts are provisioned via the seed script, not public self sign-up. + disableSignUp: true, + }, + session: { + expiresIn: 60 * 60 * 12, + }, + // `tanstackStartCookies` must stay last so it sees every other plugin's Set-Cookie headers. + plugins: [admin(), tanstackStartCookies()], + }) +} + +let authInstance: ReturnType | undefined + +// Lazily constructed so nothing here runs at module load time — a top-level +// `betterAuth(...)` call would get pulled into the client bundle (and with it +// the Postgres driver) by any route file that imports a sibling export. +export function getAuth() { + authInstance ??= createAuth() + return authInstance +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..cb3e248 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,22 @@ +import { createServerFn } from '@tanstack/react-start' +import { getRequest } from '@tanstack/react-start/server' + +export async function getStaffSession() { + const { getAuth } = await import('#/lib/auth-instance') + return getAuth().api.getSession({ headers: getRequest().headers }) +} + +export async function requireStaffSession() { + const session = await getStaffSession() + + if (!session) { + throw new Error('Not signed in') + } + + return session +} + +export const getCurrentStaffUser = createServerFn({ method: 'GET' }).handler(async () => { + const session = await getStaffSession() + return session?.user ?? null +}) diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts new file mode 100644 index 0000000..ed649c5 --- /dev/null +++ b/src/lib/datetime.ts @@ -0,0 +1,4 @@ +export function formatDateTimeLocal(date: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} diff --git a/src/lib/reservations.ts b/src/lib/reservations.ts index 5b5b87f..bfcf1a0 100644 --- a/src/lib/reservations.ts +++ b/src/lib/reservations.ts @@ -1,15 +1,18 @@ import { createServerFn } from '@tanstack/react-start' -import { and, eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { z } from 'zod' import { db } from '#/db' import { reservations, restaurantTables } from '#/db/schema' +import { requireStaffSession } from '#/lib/auth' export const getTables = createServerFn({ method: 'GET' }).handler(async () => { return db.select().from(restaurantTables).orderBy(restaurantTables.name) }) export const getReservations = createServerFn({ method: 'GET' }).handler(async () => { + await requireStaffSession() + return db .select({ id: reservations.id, @@ -84,3 +87,79 @@ export const createReservation = createServerFn({ method: 'POST' }) return reservation }) + +const updateReservationSchema = createReservationSchema.extend({ + id: z.number().int().positive(), +}) + +export const updateReservation = createServerFn({ method: 'POST' }) + .validator(updateReservationSchema) + .handler(async ({ data }) => { + await requireStaffSession() + + const reservationTime = new Date(data.reservationTime) + + const [table] = await db + .select() + .from(restaurantTables) + .where(eq(restaurantTables.id, data.tableId)) + + if (!table) { + throw new Error('Selected table does not exist') + } + + if (data.partySize > table.seats) { + throw new Error(`${table.name} only seats up to ${table.seats} guests`) + } + + const [conflict] = await db + .select() + .from(reservations) + .where( + and( + eq(reservations.tableId, data.tableId), + eq(reservations.reservationTime, reservationTime), + ne(reservations.id, data.id), + ), + ) + + if (conflict) { + throw new Error('This table is already booked for that time') + } + + const [updated] = await db + .update(reservations) + .set({ + tableId: data.tableId, + customerName: data.customerName, + customerEmail: data.customerEmail, + partySize: data.partySize, + reservationTime, + }) + .where(eq(reservations.id, data.id)) + .returning() + + if (!updated) { + throw new Error('Reservation not found') + } + + return updated + }) + +const deleteReservationSchema = z.object({ + id: z.number().int().positive(), +}) + +export const deleteReservation = createServerFn({ method: 'POST' }) + .validator(deleteReservationSchema) + .handler(async ({ data }) => { + await requireStaffSession() + + const [deleted] = await db.delete(reservations).where(eq(reservations.id, data.id)).returning() + + if (!deleted) { + throw new Error('Reservation not found') + } + + return deleted + }) diff --git a/src/lib/staff.ts b/src/lib/staff.ts new file mode 100644 index 0000000..d94cb91 --- /dev/null +++ b/src/lib/staff.ts @@ -0,0 +1,15 @@ +import { createServerFn } from '@tanstack/react-start' + +export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async () => { + const [{ getAuth }, { getRequest }] = await Promise.all([ + import('#/lib/auth-instance'), + import('@tanstack/react-start/server'), + ]) + + const { users } = await getAuth().api.listUsers({ + headers: getRequest().headers, + query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 }, + }) + + return users +}) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 657dfbc..379ed06 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as ImpressumRouteImport } from './routes/impressum' +import { Route as StaffIndexRouteImport } from './routes/staff.index' +import { Route as StaffLoginRouteImport } from './routes/staff.login' +import { Route as StaffTeamRouteImport } from './routes/staff.team' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -22,31 +25,59 @@ const ImpressumRoute = ImpressumRouteImport.update({ path: '/impressum', getParentRoute: () => rootRouteImport, } as any) +const StaffIndexRoute = StaffIndexRouteImport.update({ + id: '/staff/', + path: '/staff/', + getParentRoute: () => rootRouteImport, +} as any) +const StaffLoginRoute = StaffLoginRouteImport.update({ + id: '/staff/login', + path: '/staff/login', + getParentRoute: () => rootRouteImport, +} as any) +const StaffTeamRoute = StaffTeamRouteImport.update({ + id: '/staff/team', + path: '/staff/team', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/impressum': typeof ImpressumRoute + '/staff/login': typeof StaffLoginRoute + '/staff/team': typeof StaffTeamRoute + '/staff/': typeof StaffIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/impressum': typeof ImpressumRoute + '/staff/login': typeof StaffLoginRoute + '/staff/team': typeof StaffTeamRoute + '/staff': typeof StaffIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/impressum': typeof ImpressumRoute + '/staff/login': typeof StaffLoginRoute + '/staff/team': typeof StaffTeamRoute + '/staff/': typeof StaffIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/impressum' + fullPaths: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/impressum' - id: '__root__' | '/' | '/impressum' + to: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff' + id: + '__root__' | '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImpressumRoute: typeof ImpressumRoute + StaffLoginRoute: typeof StaffLoginRoute + StaffTeamRoute: typeof StaffTeamRoute + StaffIndexRoute: typeof StaffIndexRoute } declare module '@tanstack/react-router' { @@ -65,12 +96,36 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ImpressumRouteImport parentRoute: typeof rootRouteImport } + '/staff/': { + id: '/staff/' + path: '/staff' + fullPath: '/staff/' + preLoaderRoute: typeof StaffIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/staff/login': { + id: '/staff/login' + path: '/staff/login' + fullPath: '/staff/login' + preLoaderRoute: typeof StaffLoginRouteImport + parentRoute: typeof rootRouteImport + } + '/staff/team': { + id: '/staff/team' + path: '/staff/team' + fullPath: '/staff/team' + preLoaderRoute: typeof StaffTeamRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImpressumRoute: ImpressumRoute, + StaffLoginRoute: StaffLoginRoute, + StaffTeamRoute: StaffTeamRoute, + StaffIndexRoute: StaffIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/index.tsx b/src/routes/index.tsx index cba3c4f..fe38fe8 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,6 +3,7 @@ import { createFileRoute, useRouter } from '@tanstack/react-router' import { SiteFooter } from '#/components/site-footer' import { SiteHeader } from '#/components/site-header' +import { formatDateTimeLocal } from '#/lib/datetime' import { createReservation, getTables } from '#/lib/reservations' export const Route = createFileRoute('/')({ @@ -23,11 +24,6 @@ const OPENING_HOURS = [ { day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] }, ] -function formatDateTimeLocal(date: Date) { - const pad = (n: number) => String(n).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` -} - function FlameIcon() { return (
+
+ + Steakhaus bei Mirko + +
+ {staffUser.role === 'admin' && ( + + Mitarbeiter + + )} + Angemeldet als {staffUser.name} + +
+
+ +
+

Reservierungen

+ +
+
+ {reservations.length} + Reservierungen gesamt +
+
+ {todaysReservations.length} + Heute +
+
+ {guestsToday} + Gäste heute +
+
+ {upcoming.length} + Anstehend +
+
+ +
+

+ {editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'} +

+ +
+ + +
+ + + +
+ +
+ + + +
+ +
+ + {editingId && ( + + )} +
+ + {error &&

{error}

} + {success &&

{success}

} +
+
+ +
+ {reservations.length === 0 ? ( +

Noch keine Reservierungen vorhanden.

+ ) : ( +
+ + + + + + + + + + + + {reservations.map((reservation) => ( + + + + + + + + ))} + +
Datum & UhrzeitTischKundePersonen
{dateTimeFormatter.format(new Date(reservation.reservationTime))}{reservation.tableName} + {reservation.customerName} +
+ {reservation.customerEmail} +
{reservation.partySize} + + +
+
+ )} +
+
+
+ ) +} diff --git a/src/routes/staff.login.tsx b/src/routes/staff.login.tsx new file mode 100644 index 0000000..c106083 --- /dev/null +++ b/src/routes/staff.login.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' + +import { getCurrentStaffUser } from '#/lib/auth' +import { authClient } from '#/lib/auth-client' + +export const Route = createFileRoute('/staff/login')({ + component: StaffLogin, + beforeLoad: async () => { + const staffUser = await getCurrentStaffUser() + if (staffUser) { + throw redirect({ to: '/staff' }) + } + }, +}) + +function StaffLogin() { + const router = useRouter() + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError(null) + setIsSubmitting(true) + + const { error: signInError } = await authClient.signIn.email({ email, password }) + + if (signInError) { + setError(signInError.message ?? 'Etwas ist schiefgelaufen') + setIsSubmitting(false) + return + } + + await router.navigate({ to: '/staff' }) + } + + return ( +
+
+
+ Mitarbeiterbereich +

Anmelden

+
+ + + + + + + + {error &&

{error}

} +
+
+ ) +} diff --git a/src/routes/staff.team.tsx b/src/routes/staff.team.tsx new file mode 100644 index 0000000..7956f9f --- /dev/null +++ b/src/routes/staff.team.tsx @@ -0,0 +1,295 @@ +import { useState } from 'react' +import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' + +import { getCurrentStaffUser } from '#/lib/auth' +import { authClient } from '#/lib/auth-client' +import { listStaffMembers } from '#/lib/staff' + +export const Route = createFileRoute('/staff/team')({ + component: StaffTeam, + beforeLoad: async () => { + const staffUser = await getCurrentStaffUser() + if (!staffUser) { + throw redirect({ to: '/staff/login' }) + } + if (staffUser.role !== 'admin') { + throw redirect({ to: '/staff' }) + } + return { staffUser } + }, + loader: async ({ context }) => { + const members = await listStaffMembers() + return { staffUser: context.staffUser, members } + }, +}) + +type StaffMember = Awaited>[number] + +type FormState = { + name: string + email: string + password: string + role: 'admin' | 'user' +} + +function emptyForm(): FormState { + return { name: '', email: '', password: '', role: 'user' } +} + +function StaffTeam() { + const router = useRouter() + const { staffUser, members } = Route.useLoaderData() + + const [form, setForm] = useState(emptyForm) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [busyId, setBusyId] = useState(null) + + async function handleCreate(event: React.FormEvent) { + event.preventDefault() + setError(null) + setSuccess(null) + setIsSubmitting(true) + + const { error: createError } = await authClient.admin.createUser({ + name: form.name, + email: form.email, + password: form.password, + role: form.role, + }) + + if (createError) { + setError(createError.message ?? 'Etwas ist schiefgelaufen') + setIsSubmitting(false) + return + } + + setSuccess('Mitarbeiter wurde angelegt.') + setForm(emptyForm()) + setIsSubmitting(false) + await router.invalidate() + } + + async function handleRemove(member: StaffMember) { + if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) { + return + } + + setBusyId(member.id) + setError(null) + setSuccess(null) + + const { error: removeError } = await authClient.admin.removeUser({ userId: member.id }) + + if (removeError) { + setError(removeError.message ?? 'Etwas ist schiefgelaufen') + } else { + setSuccess('Zugang wurde entfernt.') + await router.invalidate() + } + + setBusyId(null) + } + + async function handleToggleRole(member: StaffMember) { + const nextRole = member.role === 'admin' ? 'user' : 'admin' + setBusyId(member.id) + setError(null) + setSuccess(null) + + const { error: roleError } = await authClient.admin.setRole({ userId: member.id, role: nextRole }) + + if (roleError) { + setError(roleError.message ?? 'Etwas ist schiefgelaufen') + } else { + setSuccess('Rolle wurde aktualisiert.') + await router.invalidate() + } + + setBusyId(null) + } + + async function handleToggleBan(member: StaffMember) { + setBusyId(member.id) + setError(null) + setSuccess(null) + + const { error: banError } = member.banned + ? await authClient.admin.unbanUser({ userId: member.id }) + : await authClient.admin.banUser({ userId: member.id }) + + if (banError) { + setError(banError.message ?? 'Etwas ist schiefgelaufen') + } else { + setSuccess(member.banned ? 'Zugang wurde reaktiviert.' : 'Zugang wurde gesperrt.') + await router.invalidate() + } + + setBusyId(null) + } + + async function handleSetPassword(member: StaffMember) { + const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`) + + if (!newPassword) { + return + } + + setBusyId(member.id) + setError(null) + setSuccess(null) + + const { error: passwordError } = await authClient.admin.setUserPassword({ + userId: member.id, + newPassword, + }) + + if (passwordError) { + setError(passwordError.message ?? 'Etwas ist schiefgelaufen') + } else { + setSuccess(`Passwort für ${member.name} wurde geändert.`) + } + + setBusyId(null) + } + + return ( +
+
+ + Steakhaus bei Mirko + +
+ + Zu den Reservierungen + + Angemeldet als {staffUser.name} +
+
+ +
+

Mitarbeiter

+ +
+

Neuer Mitarbeiter

+ +
+
+ + + +
+ +
+ + + +
+ + + + {error &&

{error}

} + {success &&

{success}

} +
+
+ +
+
+ + + + + + + + + + + + {members.map((member) => ( + + + + + + + + ))} + +
NameE-MailRolleStatus
{member.name}{member.email}{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}{member.banned ? 'Gesperrt' : 'Aktiv'} + + + + +
+
+
+
+
+ ) +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..7544770 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,21 @@ +import { createStartHandler, defaultStreamHandler } from '@tanstack/react-start/server' +import type { Register } from '@tanstack/react-router' +import type { RequestHandler } from '@tanstack/react-start/server' + +import { getAuth } from '#/lib/auth-instance' + +const startFetch = createStartHandler(defaultStreamHandler) + +export type ServerEntry = { fetch: RequestHandler } + +const fetch: RequestHandler = (request, opts) => { + const url = new URL(request.url) + + if (url.pathname.startsWith('/api/auth')) { + return getAuth().handler(request) + } + + return startFetch(request, opts) +} + +export default { fetch } satisfies ServerEntry diff --git a/src/styles.css b/src/styles.css index bf378ad..966388a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -408,6 +408,191 @@ a { color: var(--gold-bright); } +/* Staff dashboard */ + +.staff-page { + min-height: 100vh; +} + +.staff-topbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--line); +} + +.staff-topbar-right { + display: flex; + align-items: center; + gap: 1rem; + color: var(--text-muted); + font-size: 0.9rem; +} + +.staff-topbar-right .btn { + padding: 0.5rem 1rem; +} + +.staff-main { + padding: 3rem 1.5rem 4rem; +} + +.staff-subheading { + font-size: 1.35rem; + margin: 2.5rem 0 1.25rem; +} + +.staff-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-top: 1.5rem; +} + +@media (max-width: 40rem) { + .staff-stats { + grid-template-columns: repeat(2, 1fr); + } +} + +.stat-card { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1.25rem; + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: 0.5rem; +} + +.stat-value { + font-family: 'Fraunces', Georgia, serif; + font-size: 1.75rem; + color: var(--gold-bright); +} + +.stat-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.staff-form-section { + max-width: 36rem; +} + +.form-actions { + display: flex; + gap: 0.75rem; + align-items: center; +} + +.form-actions .btn { + font: inherit; + padding: 0.7rem 1.5rem; +} + +.staff-table-section { + margin-top: 2.5rem; +} + +.table-scroll { + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 0.5rem; +} + +.reservations-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.reservations-table th, +.reservations-table td { + padding: 0.85rem 1rem; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid var(--line); +} + +.reservations-table th { + color: var(--gold); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + background: var(--bg-raised); +} + +.reservations-table tbody tr:last-child td { + border-bottom: none; +} + +.reservations-table tr.is-editing { + background: rgba(201, 161, 90, 0.08); +} + +.reservations-table .muted { + color: var(--text-muted); + font-size: 0.8rem; +} + +.table-actions { + display: flex; + gap: 0.5rem; +} + +.table-actions .btn { + font: inherit; + padding: 0.4rem 0.85rem; + font-size: 0.8rem; +} + +.btn-danger { + background: transparent; + border-color: #5c1f1f; + color: #f3a9a9; +} + +.btn-danger:hover:not(:disabled) { + background: #3a1414; +} + +.btn-danger:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.empty-state { + color: var(--text-muted); + font-style: italic; +} + +.staff-login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1.5rem; +} + +.staff-login-form { + width: 100%; + max-width: 24rem; +} + +.staff-login-heading { + text-align: center; +} + +.staff-login-heading .section-heading { + font-size: 1.5rem; +} + /* Legal page */ .legal-page {