diff --git a/package-lock.json b/package-lock.json index ae3a141..7d78caf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "next-auth": "^5.0.0-beta.25", "openai": "^6.25.0", "prisma": "^6.19.2", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "sharp": "^0.34.5", @@ -31,6 +32,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", @@ -2738,6 +2740,16 @@ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "dev": true }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -3321,7 +3333,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -3330,7 +3341,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3763,6 +3773,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3866,6 +3885,51 @@ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3879,7 +3943,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3890,8 +3953,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/commander": { "version": "4.1.1", @@ -4036,6 +4098,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4111,6 +4182,12 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5049,6 +5126,15 @@ "node": ">= 0.4" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5574,7 +5660,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -6604,6 +6689,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6620,7 +6714,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "engines": { "node": ">=8" } @@ -6719,6 +6812,15 @@ "pathe": "^2.0.3" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -6972,6 +7074,23 @@ ], "license": "MIT" }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -7091,6 +7210,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7267,6 +7401,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7673,7 +7813,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8231,6 +8370,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -8361,6 +8506,119 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a5e65de..8963e3f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "next-auth": "^5.0.0-beta.25", "openai": "^6.25.0", "prisma": "^6.19.2", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "sharp": "^0.34.5", @@ -32,6 +33,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^18", "@types/react-dom": "^18", "eslint": "^8", diff --git a/src/app/(app)/admin/invites/page.tsx b/src/app/(app)/admin/invites/page.tsx new file mode 100644 index 0000000..6638a77 --- /dev/null +++ b/src/app/(app)/admin/invites/page.tsx @@ -0,0 +1,20 @@ +import { Header } from "@/components/layout/header" +import { InvitesClient } from "@/components/admin/invites-client" + +export default function AdminInvitesPage() { + return ( +
+
+
+
+

Invites

+

+ Signing up requires an invitation. Share a link or have someone scan the + QR code. +

+
+ +
+
+ ) +} diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx new file mode 100644 index 0000000..ef5a4a9 --- /dev/null +++ b/src/app/(app)/admin/layout.tsx @@ -0,0 +1,20 @@ +import { notFound } from "next/navigation" +import { auth } from "@/lib/auth" + +/** + * Owner-only area. + * + * 404 rather than 403 so the admin area's existence is not advertised to members. + * This is the real gate - hiding the nav links is only presentation, and every + * /api/admin route independently calls requireOwner(). + */ +export default async function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const session = await auth() + if (session?.user?.role !== "OWNER") notFound() + + return <>{children} +} diff --git a/src/app/(app)/admin/users/page.tsx b/src/app/(app)/admin/users/page.tsx new file mode 100644 index 0000000..542ee1c --- /dev/null +++ b/src/app/(app)/admin/users/page.tsx @@ -0,0 +1,20 @@ +import { Header } from "@/components/layout/header" +import { UsersClient } from "@/components/admin/users-client" + +export default function AdminUsersPage() { + return ( +
+
+
+
+

People

+

+ Everyone with an account, what they have added, and what their AI use has + cost over the last 30 days. +

+
+ +
+
+ ) +} diff --git a/src/app/(auth)/reset/[token]/page.tsx b/src/app/(auth)/reset/[token]/page.tsx new file mode 100644 index 0000000..9bc0f54 --- /dev/null +++ b/src/app/(auth)/reset/[token]/page.tsx @@ -0,0 +1,57 @@ +import Link from "next/link" +import { KeyRound } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { ResetForm } from "@/components/auth/reset-form" +import { prisma } from "@/lib/prisma" + +export const dynamic = "force-dynamic" + +export default async function ResetPage({ + params, +}: { + params: { token: string } +}) { + const reset = await prisma.passwordReset.findUnique({ + where: { token: params.token }, + select: { usedAt: true, expiresAt: true }, + }) + const usable = !!reset && !reset.usedAt && reset.expiresAt > new Date() + + return ( +
+ + +
+ +
+ Set a new password + + {usable + ? "Choose a new password for your account." + : "That reset link is no longer valid. Ask the owner for a new one."} + +
+ + {usable ? ( + + ) : ( +

+ + Back to sign in + +

+ )} +
+
+
+ ) +} diff --git a/src/app/api/admin/invites/[id]/qr/route.ts b/src/app/api/admin/invites/[id]/qr/route.ts new file mode 100644 index 0000000..0d87563 --- /dev/null +++ b/src/app/api/admin/invites/[id]/qr/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import QRCode from "qrcode" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { inviteUrl } from "@/lib/invites" +import { publicOrigin } from "@/lib/origin" + +/** + * QR code for an invite link, as an SVG. + * + * Generated locally by node-qrcode - no external service, which matters because the + * CSP forbids loading from anywhere but this origin. Served as image/svg+xml so it + * can be used in a plain , which `img-src 'self'` already allows. + */ +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const invite = await prisma.invite.findUnique({ + where: { id: params.id }, + select: { token: true }, + }) + if (!invite) return new Response(null, { status: 404 }) + + const svg = await QRCode.toString(inviteUrl(invite.token, publicOrigin(request)), { + type: "svg", + margin: 1, + width: 320, + errorCorrectionLevel: "M", + }) + + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml", + // The link is a bearer token; keep it out of shared caches. + "Cache-Control": "private, no-store", + }, + }) +} diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts new file mode 100644 index 0000000..0c0a62b --- /dev/null +++ b/src/app/api/admin/invites/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" + +/** + * Revokes rather than deletes, so the redemption history of an already-used invite + * survives - that is the record of who invited whom. + */ +export async function DELETE( + _request: Request, + { params }: { params: { id: string } } +) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const invite = await prisma.invite.findUnique({ where: { id: params.id } }) + if (!invite) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + await prisma.invite.update({ + where: { id: params.id }, + data: { revokedAt: new Date() }, + }) + + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts new file mode 100644 index 0000000..f3209b7 --- /dev/null +++ b/src/app/api/admin/invites/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { generateInviteToken } from "@/lib/invites" + +const createSchema = z.object({ + label: z.string().max(100).optional(), + maxUses: z.number().int().min(1).max(50).default(1), + expiresInHours: z.number().int().min(1).max(24 * 90).nullable().optional(), +}) + +export async function GET() { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const invites = await prisma.invite.findMany({ + orderBy: { createdAt: "desc" }, + include: { + redemptions: { + select: { id: true, email: true, createdAt: true }, + }, + }, + }) + + return NextResponse.json({ invites }) +} + +export async function POST(request: Request) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const parsed = createSchema.safeParse(await request.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid input", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { label, maxUses, expiresInHours } = parsed.data + + const invite = await prisma.invite.create({ + data: { + token: generateInviteToken(), + label: label?.trim() || null, + createdById: session.user.id, + maxUses, + expiresAt: expiresInHours + ? new Date(Date.now() + expiresInHours * 60 * 60 * 1000) + : null, + }, + }) + + return NextResponse.json({ invite }, { status: 201 }) +} diff --git a/src/app/api/admin/users/[id]/reset-link/route.ts b/src/app/api/admin/users/[id]/reset-link/route.ts new file mode 100644 index 0000000..0c33fd9 --- /dev/null +++ b/src/app/api/admin/users/[id]/reset-link/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { publicOrigin } from "@/lib/origin" + +const RESET_TTL_HOURS = 24 + +/** + * Issues a single-use password reset link for a member. + * + * There is no email infrastructure, so the owner delivers this out of band, the same + * way an invite is delivered. Without it a member who forgets their password is + * permanently locked out and the owner has no way to help. + * + * Any unused reset for the same user is invalidated first, so only the newest link works. + */ +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const user = await prisma.user.findUnique({ + where: { id: params.id }, + select: { id: true, email: true, password: true }, + }) + if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 }) + if (!user.password) { + return NextResponse.json( + { error: "That account does not use a password" }, + { status: 400 } + ) + } + + const token = randomBytes(32).toString("hex") + + await prisma.$transaction([ + prisma.passwordReset.updateMany({ + where: { userId: params.id, usedAt: null }, + data: { usedAt: new Date() }, + }), + prisma.passwordReset.create({ + data: { + token, + userId: params.id, + expiresAt: new Date(Date.now() + RESET_TTL_HOURS * 60 * 60 * 1000), + }, + }), + ]) + + return NextResponse.json({ + url: `${publicOrigin(request)}/reset/${token}`, + expiresInHours: RESET_TTL_HOURS, + }) +} diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..f03b942 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { deleteImage } from "@/lib/s3" +import { revokeGatewayKey } from "@/lib/ai/switchboard-keys" + +const patchSchema = z.object({ + status: z.enum(["ACTIVE", "SUSPENDED"]).optional(), + aiEnabled: z.boolean().optional(), + aiDailyBudgetUsd: z.number().min(0).max(100).nullable().optional(), +}) + +/** Refuse to strand the deployment without a usable owner. */ +async function guardLastOwner(targetId: string): Promise { + const target = await prisma.user.findUnique({ + where: { id: targetId }, + select: { role: true }, + }) + if (!target) return "Not found" + if (target.role !== "OWNER") return null + + const activeOwners = await prisma.user.count({ + where: { role: "OWNER", status: "ACTIVE" }, + }) + return activeOwners <= 1 ? "Cannot suspend or delete the only owner" : null +} + +export async function PATCH( + request: Request, + { params }: { params: { id: string } } +) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const parsed = patchSchema.safeParse(await request.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }) + } + + if (parsed.data.status === "SUSPENDED") { + if (params.id === session.user.id) { + return NextResponse.json( + { error: "You cannot suspend your own account" }, + { status: 400 } + ) + } + const problem = await guardLastOwner(params.id) + if (problem) return NextResponse.json({ error: problem }, { status: 400 }) + } + + const user = await prisma.user.update({ + where: { id: params.id }, + data: parsed.data, + select: { id: true, status: true, aiEnabled: true, aiDailyBudgetUsd: true }, + }) + + return NextResponse.json({ user }) +} + +export async function DELETE( + _request: Request, + { params }: { params: { id: string } } +) { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + if (params.id === session.user.id) { + return NextResponse.json( + { error: "You cannot delete your own account" }, + { status: 400 } + ) + } + const problem = await guardLastOwner(params.id) + if (problem) { + return NextResponse.json({ error: problem }, { status: problem === "Not found" ? 404 : 400 }) + } + + // Collect what the cascade will make unreachable, before it happens. + const [drinks, barItems, scans, keys] = await Promise.all([ + prisma.drink.findMany({ where: { userId: params.id }, select: { imageUrl: true } }), + prisma.barItem.findMany({ where: { userId: params.id }, select: { imageUrl: true } }), + prisma.menuScan.findMany({ where: { userId: params.id }, select: { imageUrl: true } }), + prisma.userApiKey.findMany({ + where: { userId: params.id }, + select: { gatewayKeyId: true }, + }), + ]) + + await prisma.user.delete({ where: { id: params.id } }) + + // Best effort after the fact: never let storage or the gateway block a deletion + // the user asked for, and never leave the account half-deleted. + const keyPrefix = "/minio-images/" + const objectKeys = [...drinks, ...barItems, ...scans] + .map((r) => r.imageUrl) + .filter((u): u is string => !!u && u.startsWith(keyPrefix)) + .map((u) => u.slice(keyPrefix.length)) + + await Promise.allSettled([ + ...objectKeys.map((k) => deleteImage(k)), + ...keys + .map((k) => k.gatewayKeyId) + .filter((id): id is string => !!id) + .map((id) => revokeGatewayKey(id)), + ]) + + return NextResponse.json({ + success: true, + imagesDeleted: objectKeys.length, + gatewayKeysRevoked: keys.filter((k) => k.gatewayKeyId).length, + }) +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..96cfae5 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import { requireOwner } from "@/lib/authz" +import { prisma } from "@/lib/prisma" + +export async function GET() { + const session = await requireOwner() + if (session instanceof NextResponse) return session + + const users = await prisma.user.findMany({ + orderBy: { createdAt: "asc" }, + select: { + id: true, + name: true, + email: true, + role: true, + status: true, + aiEnabled: true, + aiDailyBudgetUsd: true, + createdAt: true, + _count: { select: { drinks: true, ratings: true, menuScans: true } }, + }, + }) + + // Spend for the trailing 30 days, grouped in one query rather than per user. + const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + const spend = await prisma.aiCall.groupBy({ + by: ["userId"], + where: { createdAt: { gte: since } }, + _sum: { costUsd: true }, + _count: { _all: true }, + }) + const spendByUser = new Map( + spend.map((s) => [s.userId, { usd: s._sum.costUsd ?? 0, calls: s._count._all }]) + ) + + return NextResponse.json({ + users: users.map((u) => ({ + ...u, + aiSpend30d: spendByUser.get(u.id) ?? { usd: 0, calls: 0 }, + })), + }) +} diff --git a/src/app/api/auth/reset/route.ts b/src/app/api/auth/reset/route.ts new file mode 100644 index 0000000..b97eee0 --- /dev/null +++ b/src/app/api/auth/reset/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import bcrypt from "bcryptjs" +import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rate-limit" + +const resetSchema = z.object({ + token: z.string().min(32).max(128), + password: z + .string() + .min(10, "Password must be at least 10 characters") + .max(128, "Password must be 128 characters or less") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[0-9]/, "Password must contain at least one number"), +}) + +export async function POST(request: Request) { + const parsed = resetSchema.safeParse(await request.json().catch(() => ({}))) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0]?.message ?? "Invalid input" }, + { status: 400 } + ) + } + + const { token, password } = parsed.data + + // Keyed on the token so guessing one costs attempts against that token alone. + const rl = rateLimit(`reset:${token.slice(0, 16)}`, 5, 15 * 60 * 1000) + if (!rl.success) { + return NextResponse.json( + { error: "Too many attempts. Please try again later." }, + { status: 429 } + ) + } + + const reset = await prisma.passwordReset.findUnique({ where: { token } }) + // One message for every failure mode, so this cannot be used to probe which + // tokens exist or have been used. + if (!reset || reset.usedAt || reset.expiresAt <= new Date()) { + return NextResponse.json( + { error: "That reset link is no longer valid. Ask the owner for a new one." }, + { status: 400 } + ) + } + + const hashed = await bcrypt.hash(password, 10) + + await prisma.$transaction([ + prisma.user.update({ + where: { id: reset.userId }, + data: { password: hashed }, + }), + prisma.passwordReset.update({ + where: { id: reset.id }, + data: { usedAt: new Date() }, + }), + ]) + + return NextResponse.json({ success: true }) +} diff --git a/src/app/invite/[token]/route.ts b/src/app/invite/[token]/route.ts index 9400162..ee02c3d 100644 --- a/src/app/invite/[token]/route.ts +++ b/src/app/invite/[token]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invites" +import { publicOrigin } from "@/lib/origin" /** * Entry point for an invite link or QR code. @@ -13,20 +14,6 @@ import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invit */ export const dynamic = "force-dynamic" -/** - * The app binds 0.0.0.0:3000 behind a reverse proxy, so `request.url` carries the - * internal address and redirects built from it are unreachable. NEXTAUTH_URL is the - * configured public origin and cannot be influenced by a request header. - */ -function publicOrigin(request: Request): string { - const configured = process.env.NEXTAUTH_URL - if (configured) return configured.replace(/\/$/, "") - - const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") - const proto = request.headers.get("x-forwarded-proto") ?? "https" - return host ? `${proto}://${host}` : new URL(request.url).origin -} - export async function GET( request: Request, { params }: { params: { token: string } } diff --git a/src/components/admin/invites-client.tsx b/src/components/admin/invites-client.tsx new file mode 100644 index 0000000..95c7c55 --- /dev/null +++ b/src/components/admin/invites-client.tsx @@ -0,0 +1,271 @@ +"use client" + +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Check, Copy, Loader2, QrCode, Plus, Ban } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectOption } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +interface Redemption { + id: string + email: string | null + createdAt: string +} + +interface Invite { + id: string + token: string + label: string | null + maxUses: number + usedCount: number + expiresAt: string | null + revokedAt: string | null + createdAt: string + redemptions: Redemption[] +} + +const EXPIRY_OPTIONS = [ + { value: "24", label: "24 hours" }, + { value: "168", label: "7 days" }, + { value: "720", label: "30 days" }, + { value: "", label: "Never" }, +] + +function statusOf(invite: Invite): { label: string; tone: string } { + if (invite.revokedAt) return { label: "Revoked", tone: "text-muted-foreground" } + if (invite.expiresAt && new Date(invite.expiresAt) <= new Date()) + return { label: "Expired", tone: "text-muted-foreground" } + if (invite.usedCount >= invite.maxUses) + return { label: "Used", tone: "text-muted-foreground" } + return { label: "Active", tone: "text-green-600" } +} + +export function InvitesClient() { + const queryClient = useQueryClient() + const [label, setLabel] = useState("") + const [maxUses, setMaxUses] = useState("1") + const [expiry, setExpiry] = useState("168") + const [copied, setCopied] = useState(null) + const [showQr, setShowQr] = useState(null) + + const { data, isLoading } = useQuery<{ invites: Invite[] }>({ + queryKey: ["admin-invites"], + queryFn: async () => { + const res = await fetch("/api/admin/invites") + if (!res.ok) throw new Error("Failed to load invites") + return res.json() + }, + }) + + const create = useMutation({ + mutationFn: async () => { + const res = await fetch("/api/admin/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + label: label.trim() || undefined, + maxUses: Number(maxUses) || 1, + expiresInHours: expiry ? Number(expiry) : null, + }), + }) + if (!res.ok) throw new Error("Failed to create invite") + return res.json() + }, + onSuccess: () => { + setLabel("") + queryClient.invalidateQueries({ queryKey: ["admin-invites"] }) + }, + }) + + const revoke = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/admin/invites/${id}`, { method: "DELETE" }) + if (!res.ok) throw new Error("Failed to revoke invite") + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-invites"] }), + }) + + function linkFor(invite: Invite) { + return `${window.location.origin}/invite/${invite.token}` + } + + async function copy(invite: Invite) { + await navigator.clipboard.writeText(linkFor(invite)) + setCopied(invite.id) + setTimeout(() => setCopied(null), 2000) + } + + const invites = data?.invites ?? [] + + return ( + <> + + + + + New invite + + + Anyone holding the link can sign up, so prefer a single use and a short + expiry. + + + +
+
+ + setLabel(e.target.value)} + maxLength={100} + /> +
+
+ + setMaxUses(e.target.value)} + /> +
+
+ + +
+
+ + {create.isError && ( +

Could not create that invite.

+ )} +
+
+ + + + Existing invites + + + {isLoading && ( +

Loading...

+ )} + {!isLoading && invites.length === 0 && ( +

+ No invites yet. Create one above to let someone join. +

+ )} + + {invites.map((invite) => { + const status = statusOf(invite) + const active = status.label === "Active" + return ( +
+
+
+

+ {invite.label || "Untitled invite"} +

+
+ + {status.label} + + + {invite.usedCount}/{invite.maxUses} used + + {invite.expiresAt && ( + + expires {new Date(invite.expiresAt).toLocaleDateString()} + + )} +
+ {invite.redemptions.length > 0 && ( +

+ Used by {invite.redemptions.map((r) => r.email).join(", ")} +

+ )} +
+ {active && ( + + )} +
+ + {active && ( + <> +
+ + +
+ + {showQr === invite.id && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Invite QR code +
+ )} + + )} +
+ ) + })} +
+
+ + ) +} diff --git a/src/components/admin/users-client.tsx b/src/components/admin/users-client.tsx new file mode 100644 index 0000000..0d6c23b --- /dev/null +++ b/src/components/admin/users-client.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Check, Copy, KeyRound, Loader2, Trash2, UserX, UserCheck } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent } from "@/components/ui/card" + +interface AdminUser { + id: string + name: string | null + email: string | null + role: "OWNER" | "MEMBER" + status: "ACTIVE" | "SUSPENDED" + aiEnabled: boolean + aiDailyBudgetUsd: number | null + createdAt: string + _count: { drinks: number; ratings: number; menuScans: number } + aiSpend30d: { usd: number; calls: number } +} + +export function UsersClient() { + const queryClient = useQueryClient() + const [resetLink, setResetLink] = useState<{ id: string; url: string } | null>(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(null) + const [confirmDelete, setConfirmDelete] = useState(null) + + const { data, isLoading } = useQuery<{ users: AdminUser[] }>({ + queryKey: ["admin-users"], + queryFn: async () => { + const res = await fetch("/api/admin/users") + if (!res.ok) throw new Error("Failed to load users") + return res.json() + }, + }) + + const patch = useMutation({ + mutationFn: async ({ id, body }: { id: string; body: Record }) => { + const res = await fetch(`/api/admin/users/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || "Update failed") + return json + }, + onSuccess: () => { + setError(null) + queryClient.invalidateQueries({ queryKey: ["admin-users"] }) + }, + onError: (e: Error) => setError(e.message), + }) + + const remove = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/admin/users/${id}`, { method: "DELETE" }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || "Delete failed") + return json + }, + onSuccess: () => { + setError(null) + setConfirmDelete(null) + queryClient.invalidateQueries({ queryKey: ["admin-users"] }) + }, + onError: (e: Error) => setError(e.message), + }) + + const makeResetLink = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/admin/users/${id}/reset-link`, { method: "POST" }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || "Could not create a reset link") + return { id, url: json.url as string } + }, + onSuccess: (r) => { + setError(null) + setResetLink(r) + }, + onError: (e: Error) => setError(e.message), + }) + + const users = data?.users ?? [] + + if (isLoading) { + return

Loading...

+ } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {users.map((user) => ( + + +
+
+
+

{user.name || "No name"}

+ {user.role === "OWNER" && ( + + Owner + + )} + {user.status === "SUSPENDED" && ( + + Suspended + + )} + {!user.aiEnabled && ( + + AI off + + )} +
+

{user.email}

+

+ {user._count.drinks} drinks · {user._count.ratings} ratings ·{" "} + {user._count.menuScans} scans · joined{" "} + {new Date(user.createdAt).toLocaleDateString()} +

+

+ AI last 30 days: ${user.aiSpend30d.usd.toFixed(4)} over{" "} + {user.aiSpend30d.calls} calls + {user.role === "MEMBER" && ( + <> · daily cap ${(user.aiDailyBudgetUsd ?? 1).toFixed(2)} + )} +

+
+
+ +
+ + + {user.role !== "OWNER" && ( + <> + + + + + {confirmDelete === user.id ? ( + <> + + + + ) : ( + + )} + + )} +
+ + {confirmDelete === user.id && ( +

+ This permanently removes their account, drinks, ratings, photos and + gateway key. It cannot be undone. +

+ )} + + {resetLink?.id === user.id && ( +
+

+ Single-use, valid 24 hours. Send it to them yourself - the app + cannot send email. +

+
+ + {resetLink.url} + + +
+
+ )} +
+
+ ))} +
+ ) +} diff --git a/src/components/auth/reset-form.tsx b/src/components/auth/reset-form.tsx new file mode 100644 index 0000000..0bbf5f3 --- /dev/null +++ b/src/components/auth/reset-form.tsx @@ -0,0 +1,98 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { Loader2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +export function ResetForm({ token }: { token: string }) { + const router = useRouter() + const [password, setPassword] = useState("") + const [confirm, setConfirm] = useState("") + const [error, setError] = useState("") + const [done, setDone] = useState(false) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError("") + + if (password !== confirm) { + setError("Passwords do not match") + return + } + + setLoading(true) + try { + const res = await fetch("/api/auth/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token, password }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || "Could not reset the password") + setLoading(false) + return + } + setDone(true) + setTimeout(() => router.push("/login"), 1500) + } catch { + setError("Something went wrong. Please try again.") + setLoading(false) + } + } + + if (done) { + return ( +

+ Password updated. Taking you to sign in... +

+ ) + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + +
+ + setPassword(e.target.value)} + required + minLength={10} + maxLength={128} + disabled={loading} + /> +
+ +
+ + setConfirm(e.target.value)} + required + minLength={10} + disabled={loading} + /> +
+ + +
+ ) +} diff --git a/src/components/layout/more-menu.tsx b/src/components/layout/more-menu.tsx index 787075d..55a2440 100644 --- a/src/components/layout/more-menu.tsx +++ b/src/components/layout/more-menu.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import Link from "next/link" import { usePathname } from "next/navigation" +import { useSession } from "next-auth/react" import { Camera, Sparkles, @@ -11,6 +12,8 @@ import { MoreHorizontal, X, BookOpen, + UserPlus, + Users, } from "lucide-react" import { cn } from "@/lib/utils" @@ -22,11 +25,21 @@ const moreItems = [ { href: "/settings", label: "Settings", icon: Settings }, ] +// Appended only for owners; the /admin layout 404s for everyone else. +const ADMIN_ITEMS = [ + { href: "/admin/invites", label: "Invites", icon: UserPlus }, + { href: "/admin/users", label: "People", icon: Users }, +] + export function MoreMenu() { const [open, setOpen] = useState(false) const pathname = usePathname() + const { data: session } = useSession() - const isActiveInMore = moreItems.some((item) => + const items = + session?.user?.role === "OWNER" ? [...moreItems, ...ADMIN_ITEMS] : moreItems + + const isActiveInMore = items.some((item) => pathname === item.href || pathname.startsWith(item.href + "/") ) @@ -57,7 +70,7 @@ export function MoreMenu() { {/* Menu */}
- {moreItems.map((item) => { + {items.map((item) => { const isActive = pathname === item.href || pathname.startsWith(item.href + "/") return ( + {session?.user?.role === "OWNER" && ( +
+

+ Admin +

+ {ADMIN_ITEMS.map((item) => { + const isActive = pathname.startsWith(item.href) + return ( + + + {item.label} + + ) + })} +
+ )} +
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 044190a..1d0d099 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -9,7 +9,14 @@ import { rateLimit } from "@/lib/rate-limit" * prefix, so `/share` also covers `/share/`. Everything else is private - * see the `authorized` callback and the denylist matcher in src/middleware.ts. */ -const PUBLIC_ROUTES = ["/login", "/register", "/join", "/invite", "/share"] +const PUBLIC_ROUTES = [ + "/login", + "/register", + "/join", + "/invite", + "/reset", + "/share", +] // Email and password only. Google and GitHub were configured but never had // credentials set and no account ever linked to them, and dropping them keeps diff --git a/src/lib/origin.ts b/src/lib/origin.ts new file mode 100644 index 0000000..3973fbe --- /dev/null +++ b/src/lib/origin.ts @@ -0,0 +1,15 @@ +/** + * The public origin of this deployment. + * + * `request.url` carries the app's internal bind address (0.0.0.0:3000) because it + * runs behind a reverse proxy, so links built from it are unreachable. NEXTAUTH_URL + * is the configured public origin and cannot be influenced by a request header. + */ +export function publicOrigin(request: Request): string { + const configured = process.env.NEXTAUTH_URL + if (configured) return configured.replace(/\/$/, "") + + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") + const proto = request.headers.get("x-forwarded-proto") ?? "https" + return host ? `${proto}://${host}` : new URL(request.url).origin +}