Add the owner admin area: invites, people, reset links
Creating an invite previously meant writing SQL by hand, which made the whole feature unusable in practice. The owner can now create invites with a use count and expiry, copy the link, show a QR code, and revoke. QR codes are generated locally by node-qrcode as inline SVG. The CSP forbids loading from any other origin, so an external generator was not an option, and img-src 'self' already covers a same-origin SVG. People lists everyone with what they have added and what their AI use has cost over 30 days, with suspend, reactivate, per-user AI toggle and delete. Deleting collects image keys and the minted gateway key id before the cascade removes the rows, then cleans both up best-effort - storage or the gateway being unavailable must not leave an account half-deleted. Guards refuse to suspend or delete yourself or the last owner. Password reset links close the gap that came with keeping email and password sign-in: with no email infrastructure, a member who forgets their password had no way back in and the owner had no way to help. The owner generates a single-use 24 hour link and delivers it the same way as an invite. Issuing one invalidates any earlier unused reset, and the reset endpoint answers identically for unknown, used and expired tokens so it cannot be used to probe which exist. The admin area 404s for members rather than 403ing, so its existence is not advertised, and every /api/admin handler independently requires the owner role. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
274
package-lock.json
generated
274
package-lock.json
generated
@@ -22,6 +22,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
@@ -2738,6 +2740,16 @@
|
|||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.28",
|
"version": "18.3.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
@@ -3321,7 +3333,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3330,7 +3341,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
},
|
},
|
||||||
@@ -3763,6 +3773,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/camelcase-css": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -3879,7 +3943,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@@ -3890,8 +3953,7 @@
|
|||||||
"node_modules/color-name": {
|
"node_modules/color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
|
"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": {
|
"node_modules/dlv": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||||
@@ -5049,6 +5126,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -5574,7 +5660,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -6604,6 +6689,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -6620,7 +6714,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -6719,6 +6812,15 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -6972,6 +7074,23 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -7091,6 +7210,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -7267,6 +7401,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -7673,7 +7813,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -8231,6 +8370,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"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==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"openai": "^6.25.0",
|
"openai": "^6.25.0",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
|
|||||||
20
src/app/(app)/admin/invites/page.tsx
Normal file
20
src/app/(app)/admin/invites/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Header } from "@/components/layout/header"
|
||||||
|
import { InvitesClient } from "@/components/admin/invites-client"
|
||||||
|
|
||||||
|
export default function AdminInvitesPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="Invites" />
|
||||||
|
<div className="p-4 md:p-8 max-w-3xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Invites</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Signing up requires an invitation. Share a link or have someone scan the
|
||||||
|
QR code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<InvitesClient />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
src/app/(app)/admin/layout.tsx
Normal file
20
src/app/(app)/admin/layout.tsx
Normal file
@@ -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}</>
|
||||||
|
}
|
||||||
20
src/app/(app)/admin/users/page.tsx
Normal file
20
src/app/(app)/admin/users/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Header } from "@/components/layout/header"
|
||||||
|
import { UsersClient } from "@/components/admin/users-client"
|
||||||
|
|
||||||
|
export default function AdminUsersPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Header title="People" />
|
||||||
|
<div className="p-4 md:p-8 max-w-3xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">People</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Everyone with an account, what they have added, and what their AI use has
|
||||||
|
cost over the last 30 days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UsersClient />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/app/(auth)/reset/[token]/page.tsx
Normal file
57
src/app/(auth)/reset/[token]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<KeyRound className="h-12 w-12 text-primary" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl">Set a new password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{usable
|
||||||
|
? "Choose a new password for your account."
|
||||||
|
: "That reset link is no longer valid. Ask the owner for a new one."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{usable ? (
|
||||||
|
<ResetForm token={params.token} />
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
42
src/app/api/admin/invites/[id]/qr/route.ts
Normal file
42
src/app/api/admin/invites/[id]/qr/route.ts
Normal file
@@ -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 <img>, 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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
27
src/app/api/admin/invites/[id]/route.ts
Normal file
27
src/app/api/admin/invites/[id]/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
56
src/app/api/admin/invites/route.ts
Normal file
56
src/app/api/admin/invites/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
57
src/app/api/admin/users/[id]/reset-link/route.ts
Normal file
57
src/app/api/admin/users/[id]/reset-link/route.ts
Normal file
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
113
src/app/api/admin/users/[id]/route.ts
Normal file
113
src/app/api/admin/users/[id]/route.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
42
src/app/api/admin/users/route.ts
Normal file
42
src/app/api/admin/users/route.ts
Normal file
@@ -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 },
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
62
src/app/api/auth/reset/route.ts
Normal file
62
src/app/api/auth/reset/route.ts
Normal file
@@ -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 })
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { INVITE_COOKIE, INVITE_COOKIE_MAX_AGE, inspectInvite } from "@/lib/invites"
|
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.
|
* 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"
|
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(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { token: string } }
|
{ params }: { params: { token: string } }
|
||||||
|
|||||||
271
src/components/admin/invites-client.tsx
Normal file
271
src/components/admin/invites-client.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [showQr, setShowQr] = useState<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
New invite
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Anyone holding the link can sign up, so prefer a single use and a short
|
||||||
|
expiry.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-2 sm:col-span-1">
|
||||||
|
<Label htmlFor="invite-label">Who is it for?</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-label"
|
||||||
|
placeholder="e.g. Dave"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-uses">Uses</Label>
|
||||||
|
<Input
|
||||||
|
id="invite-uses"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={maxUses}
|
||||||
|
onChange={(e) => setMaxUses(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="invite-expiry">Expires</Label>
|
||||||
|
<Select
|
||||||
|
id="invite-expiry"
|
||||||
|
value={expiry}
|
||||||
|
onChange={(e) => setExpiry(e.target.value)}
|
||||||
|
>
|
||||||
|
{EXPIRY_OPTIONS.map((o) => (
|
||||||
|
<SelectOption key={o.label} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectOption>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => create.mutate()} disabled={create.isPending}>
|
||||||
|
{create.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
|
Create invite
|
||||||
|
</Button>
|
||||||
|
{create.isError && (
|
||||||
|
<p className="text-sm text-destructive">Could not create that invite.</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Existing invites</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && invites.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No invites yet. Create one above to let someone join.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invites.map((invite) => {
|
||||||
|
const status = statusOf(invite)
|
||||||
|
const active = status.label === "Active"
|
||||||
|
return (
|
||||||
|
<div key={invite.id} className="rounded-md border p-3 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium truncate">
|
||||||
|
{invite.label || "Untitled invite"}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||||
|
<Badge variant="outline" className={`text-xs ${status.tone}`}>
|
||||||
|
{status.label}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{invite.usedCount}/{invite.maxUses} used
|
||||||
|
</span>
|
||||||
|
{invite.expiresAt && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
expires {new Date(invite.expiresAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{invite.redemptions.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Used by {invite.redemptions.map((r) => r.email).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{active && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => revoke.mutate(invite.id)}
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4 mr-1 text-destructive" />
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => copy(invite)}>
|
||||||
|
{copied === invite.id ? (
|
||||||
|
<Check className="h-4 w-4 mr-1 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
{copied === invite.id ? "Copied" : "Copy link"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setShowQr(showQr === invite.id ? null : invite.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<QrCode className="h-4 w-4 mr-1" />
|
||||||
|
{showQr === invite.id ? "Hide QR" : "QR code"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showQr === invite.id && (
|
||||||
|
<div className="flex justify-center bg-white rounded-md p-3">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={`/api/admin/invites/${invite.id}/qr`}
|
||||||
|
alt="Invite QR code"
|
||||||
|
className="h-56 w-56"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
264
src/components/admin/users-client.tsx
Normal file
264
src/components/admin/users-client.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(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<string, unknown> }) => {
|
||||||
|
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 <p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{users.map((user) => (
|
||||||
|
<Card key={user.id}>
|
||||||
|
<CardContent className="pt-6 space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="font-medium truncate">{user.name || "No name"}</p>
|
||||||
|
{user.role === "OWNER" && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Owner
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{user.status === "SUSPENDED" && (
|
||||||
|
<Badge variant="outline" className="text-xs text-destructive">
|
||||||
|
Suspended
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!user.aiEnabled && (
|
||||||
|
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||||
|
AI off
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{user._count.drinks} drinks · {user._count.ratings} ratings ·{" "}
|
||||||
|
{user._count.menuScans} scans · joined{" "}
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
AI last 30 days: ${user.aiSpend30d.usd.toFixed(4)} over{" "}
|
||||||
|
{user.aiSpend30d.calls} calls
|
||||||
|
{user.role === "MEMBER" && (
|
||||||
|
<> · daily cap ${(user.aiDailyBudgetUsd ?? 1).toFixed(2)}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
patch.mutate({ id: user.id, body: { aiEnabled: !user.aiEnabled } })
|
||||||
|
}
|
||||||
|
disabled={patch.isPending}
|
||||||
|
>
|
||||||
|
{user.aiEnabled ? "Turn AI off" : "Turn AI on"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user.role !== "OWNER" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
patch.mutate({
|
||||||
|
id: user.id,
|
||||||
|
body: {
|
||||||
|
status: user.status === "ACTIVE" ? "SUSPENDED" : "ACTIVE",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={patch.isPending}
|
||||||
|
>
|
||||||
|
{user.status === "ACTIVE" ? (
|
||||||
|
<>
|
||||||
|
<UserX className="h-4 w-4 mr-1" />
|
||||||
|
Suspend
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-4 w-4 mr-1" />
|
||||||
|
Reactivate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => makeResetLink.mutate(user.id)}
|
||||||
|
disabled={makeResetLink.isPending}
|
||||||
|
>
|
||||||
|
<KeyRound className="h-4 w-4 mr-1" />
|
||||||
|
Reset link
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{confirmDelete === user.id ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => remove.mutate(user.id)}
|
||||||
|
disabled={remove.isPending}
|
||||||
|
>
|
||||||
|
{remove.isPending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||||
|
) : null}
|
||||||
|
Delete everything
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmDelete(null)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfirmDelete(user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1 text-destructive" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmDelete === user.id && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
This permanently removes their account, drinks, ratings, photos and
|
||||||
|
gateway key. It cannot be undone.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{resetLink?.id === user.id && (
|
||||||
|
<div className="rounded-md border border-dashed p-3 space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Single-use, valid 24 hours. Send it to them yourself - the app
|
||||||
|
cannot send email.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded flex-1 truncate">
|
||||||
|
{resetLink.url}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await navigator.clipboard.writeText(resetLink.url)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/components/auth/reset-form.tsx
Normal file
98
src/components/auth/reset-form.tsx
Normal file
@@ -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 (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Password updated. Taking you to sign in...
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Min 10 chars, upper+lower+number"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={128}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirm">Confirm new password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
||||||
|
Update password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
|
import { useSession } from "next-auth/react"
|
||||||
import {
|
import {
|
||||||
Camera,
|
Camera,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
X,
|
X,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -22,11 +25,21 @@ const moreItems = [
|
|||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ 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() {
|
export function MoreMenu() {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
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 + "/")
|
pathname === item.href || pathname.startsWith(item.href + "/")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +70,7 @@ export function MoreMenu() {
|
|||||||
|
|
||||||
{/* Menu */}
|
{/* Menu */}
|
||||||
<div className="absolute bottom-full right-0 mb-2 z-50 bg-card border rounded-lg shadow-lg p-2 min-w-[160px]">
|
<div className="absolute bottom-full right-0 mb-2 z-50 bg-card border rounded-lg shadow-lg p-2 min-w-[160px]">
|
||||||
{moreItems.map((item) => {
|
{items.map((item) => {
|
||||||
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
|
const isActive = pathname === item.href || pathname.startsWith(item.href + "/")
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
GlassWater,
|
GlassWater,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -32,6 +34,13 @@ const navItems = [
|
|||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Rendered separately and only for owners, so members never see that an admin
|
||||||
|
// area exists. The /admin layout 404s for them regardless.
|
||||||
|
const ADMIN_ITEMS = [
|
||||||
|
{ href: "/admin/invites", label: "Invites", icon: UserPlus },
|
||||||
|
{ href: "/admin/users", label: "People", icon: Users },
|
||||||
|
]
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { data: session } = useSession()
|
const { data: session } = useSession()
|
||||||
@@ -65,6 +74,32 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{session?.user?.role === "OWNER" && (
|
||||||
|
<div className="px-2 mt-4 pt-4 border-t space-y-1">
|
||||||
|
<p className="px-3 pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
{ADMIN_ITEMS.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.href)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<HistorySection />
|
<HistorySection />
|
||||||
|
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
|
|||||||
@@ -9,7 +9,14 @@ import { rateLimit } from "@/lib/rate-limit"
|
|||||||
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
|
* prefix, so `/share` also covers `/share/<slug>`. Everything else is private -
|
||||||
* see the `authorized` callback and the denylist matcher in src/middleware.ts.
|
* 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
|
// 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
|
// credentials set and no account ever linked to them, and dropping them keeps
|
||||||
|
|||||||
15
src/lib/origin.ts
Normal file
15
src/lib/origin.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user