diff options
| -rw-r--r-- | package-lock.json | 439 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | src/app/api/user/2fa/route.ts | 97 | ||||
| -rw-r--r-- | src/app/login/page.tsx | 176 | ||||
| -rw-r--r-- | src/app/settings/page.tsx | 32 | ||||
| -rw-r--r-- | src/app/settings/settings-content.tsx | 537 | ||||
| -rw-r--r-- | src/components/ui/dialog.tsx | 143 | ||||
| -rw-r--r-- | src/lib/auth.ts | 21 | ||||
| -rw-r--r-- | src/lib/validation.ts | 6 | ||||
| -rw-r--r-- | src/model/User.ts | 5 |
10 files changed, 1379 insertions, 81 deletions
diff --git a/package-lock.json b/package-lock.json index 946a164..3acc8ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@auth/mongodb-adapter": "^3.10.0", "@hookform/resolvers": "^5.1.1", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", @@ -27,6 +28,8 @@ "next": "^16.1.1", "next-auth": "^4.24.11", "next-themes": "^0.4.6", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hook-form": "^7.59.0", @@ -40,6 +43,7 @@ "@eslint/js": "^9.39.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1387,6 +1391,53 @@ "node": ">=12.4.0" } }, + "node_modules/@otplib/core": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz", + "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==", + "license": "MIT" + }, + "node_modules/@otplib/plugin-crypto": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz", + "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1" + } + }, + "node_modules/@otplib/plugin-thirty-two": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz", + "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "thirty-two": "^1.0.2" + } + }, + "node_modules/@otplib/preset-default": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz", + "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, + "node_modules/@otplib/preset-v11": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz", + "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/plugin-crypto": "^12.0.1", + "@otplib/plugin-thirty-two": "^12.0.1" + } + }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -1605,6 +1656,98 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -2891,6 +3034,16 @@ "undici-types": "~6.21.0" } }, + "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": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", @@ -3511,11 +3664,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3947,6 +4108,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/caniuse-lite": { "version": "1.0.30001761", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", @@ -4002,6 +4172,17 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "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/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4015,7 +4196,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, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4028,7 +4208,6 @@ "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, "license": "MIT" }, "node_modules/concat-map": { @@ -4154,6 +4333,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/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -4220,6 +4408,12 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "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/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5128,6 +5322,15 @@ "node": ">=6.9.0" } }, + "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", @@ -5640,6 +5843,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7015,6 +7227,17 @@ "node": ">= 0.8.0" } }, + "node_modules/otplib": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz", + "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==", + "license": "MIT", + "dependencies": { + "@otplib/core": "^12.0.1", + "@otplib/preset-default": "^12.0.1", + "@otplib/preset-v11": "^12.0.1" + } + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7065,6 +7288,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", @@ -7082,7 +7314,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, "license": "MIT", "engines": { "node": ">=8" @@ -7124,6 +7355,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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", @@ -7221,6 +7461,23 @@ "node": ">=6" } }, + "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/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -7431,6 +7688,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", @@ -7603,6 +7875,12 @@ "semver": "bin/semver.js" } }, + "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", @@ -7903,6 +8181,26 @@ "safe-buffer": "~5.2.0" } }, + "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/string-width/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/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -8016,6 +8314,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -8131,6 +8441,14 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thirty-two": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz", + "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==", + "engines": { + "node": ">=0.2.6" + } + }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -8696,6 +9014,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.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -8727,6 +9051,20 @@ "node": ">=0.10.0" } }, + "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/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", @@ -8749,6 +9087,12 @@ "node": ">=4.0" } }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8756,6 +9100,93 @@ "dev": true, "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/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/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 4ce1571..f59bb6c 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@auth/mongodb-adapter": "^3.10.0", "@hookform/resolvers": "^5.1.1", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", @@ -28,6 +29,8 @@ "next": "^16.1.1", "next-auth": "^4.24.11", "next-themes": "^0.4.6", + "otplib": "^12.0.1", + "qrcode": "^1.5.4", "react": "^19.2.3", "react-dom": "^19.2.3", "react-hook-form": "^7.59.0", @@ -41,6 +44,7 @@ "@eslint/js": "^9.39.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/src/app/api/user/2fa/route.ts b/src/app/api/user/2fa/route.ts new file mode 100644 index 0000000..c5fcf83 --- /dev/null +++ b/src/app/api/user/2fa/route.ts @@ -0,0 +1,97 @@ +import { NextRequest, NextResponse } from "next/server" +import { getServerSession } from "next-auth" +import { authenticator } from "otplib" +import QRCode from "qrcode" +import dbConnect from "@/lib/mongodb" +import User from "@/model/User" +import { authOptions } from "@/lib/auth" + +export async function POST(req: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { code, secret } = await req.json() + + if (!code || !secret) { + return NextResponse.json( + { error: "Code and secret are required" }, + { status: 400 } + ) + } + + const isValid = authenticator.check(code, secret) + + if (!isValid) { + return NextResponse.json( + { error: "Invalid two-factor code" }, + { status: 400 } + ) + } + + await dbConnect() + await User.findByIdAndUpdate(session.user.id, { + twoFactorEnabled: true, + twoFactorSecret: secret, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("2FA enable error:", error) + return NextResponse.json( + { error: "Failed to enable two-factor authentication" }, + { status: 500 } + ) + } +} + +export async function DELETE() { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + await dbConnect() + await User.findByIdAndUpdate(session.user.id, { + twoFactorEnabled: false, + $unset: { twoFactorSecret: 1 }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("2FA disable error:", error) + return NextResponse.json( + { error: "Failed to disable two-factor authentication" }, + { status: 500 } + ) + } +} + +// Generate new secret and QR code for setup +export async function PUT() { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const secret = authenticator.generateSecret() + const otpauth = authenticator.keyuri( + session.user.email, + "Next-Boilerplate", + secret + ) + const qrCode = await QRCode.toDataURL(otpauth) + + return NextResponse.json({ secret, qrCode }) + } catch (error) { + console.error("2FA setup error:", error) + return NextResponse.json( + { error: "Failed to generate two-factor setup" }, + { status: 500 } + ) + } +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index ae89e63..c8a80f1 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -22,11 +22,14 @@ export default function SignInPage() { const [error, setError] = useState("") const router = useRouter() + const [showTwoFactor, setShowTwoFactor] = useState(false) + const form = useForm<LoginInput>({ resolver: zodResolver(loginSchema), defaultValues: { email: "", password: "", + twoFactorCode: "", }, }) @@ -38,11 +41,17 @@ export default function SignInPage() { const result = await signIn("credentials", { email: data.email, password: data.password, + twoFactorCode: data.twoFactorCode, redirect: false, }) if (result?.error) { - setError("Invalid email or password") + if (result.error === "2FA_REQUIRED") { + setShowTwoFactor(true) + // Don't clear password here so user can just enter code + } else { + setError(result.error) + } } else if (result?.ok) { router.push("/") router.refresh() @@ -62,7 +71,9 @@ export default function SignInPage() { <CardHeader className="space-y-1"> <CardTitle className="text-2xl text-center">Sign In</CardTitle> <CardDescription className="text-center"> - Enter your email and password to access your account + {showTwoFactor + ? "Enter the code from your authenticator app" + : "Enter your email and password to access your account"} </CardDescription> </CardHeader> <CardContent className="space-y-4"> @@ -74,76 +85,117 @@ export default function SignInPage() { <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input - type="email" - placeholder="john@example.com" - autoComplete="email" - {...field} - /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="password" - render={({ field }) => ( - <FormItem> - <FormLabel>Password</FormLabel> - <FormControl> - <div className="relative"> + {!showTwoFactor ? ( + <> + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + type="email" + placeholder="john@example.com" + autoComplete="email" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <div className="relative"> + <Input + type={showPassword ? "text" : "password"} + placeholder="Enter your password" + autoComplete="current-password" + {...field} + /> + <Button + type="button" + variant="ghost" + size="sm" + className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" + onClick={() => setShowPassword(!showPassword)} + > + {showPassword ? ( + <EyeOff className="h-4 w-4" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </> + ) : ( + <FormField + control={form.control} + name="twoFactorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>Two-Factor Code</FormLabel> + <FormControl> <Input - type={showPassword ? "text" : "password"} - placeholder="Enter your password" - autoComplete="current-password" + placeholder="000000" + autoComplete="one-time-code" + maxLength={6} {...field} + autoFocus /> - <Button - type="button" - variant="ghost" - size="sm" - className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" - onClick={() => setShowPassword(!showPassword)} - > - {showPassword ? ( - <EyeOff className="h-4 w-4" /> - ) : ( - <Eye className="h-4 w-4" /> - )} - </Button> - </div> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + <Button type="submit" className="w-full" disabled={isLoading}> {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Sign In + {showTwoFactor ? "Verify" : "Sign In"} </Button> </form> </Form> - <div className="text-center text-sm"> - <span className="text-muted-foreground">Don't have an account? </span> - <Link href="/signup" className="text-primary hover:underline font-medium"> - Sign up - </Link> - </div> + {!showTwoFactor && ( + <> + <div className="text-center text-sm"> + <span className="text-muted-foreground">Don't have an account? </span> + <Link href="/signup" className="text-primary hover:underline font-medium"> + Sign up + </Link> + </div> - <div className="text-center"> - <Button variant="link" className="text-sm text-muted-foreground"> - Forgot your password? - </Button> - </div> + <div className="text-center"> + <Button variant="link" className="text-sm text-muted-foreground"> + Forgot your password? + </Button> + </div> + </> + )} + + {showTwoFactor && ( + <div className="text-center"> + <Button + variant="link" + className="text-sm text-muted-foreground" + onClick={() => setShowTwoFactor(false)} + > + Back to login + </Button> + </div> + )} </CardContent> </Card> </div> diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 5d5fd92..75d9d3d 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,21 +1,31 @@ -import { redirect } from "next/navigation" import { getServerSession } from "next-auth" - -import Navbar from "@/components/Navbar" +import { redirect } from "next/navigation" +import dbConnect from "@/lib/mongodb" +import User from "@/model/User" import { authOptions } from "@/lib/auth" -import { SettingsForm } from "@/app/settings/settings-form" +import SettingsContent from "./settings-content" export default async function SettingsPage() { const session = await getServerSession(authOptions) - if (!session?.user) { + if (!session?.user?.email) { redirect("/login") } - return ( - <div className="min-h-screen bg-background"> - <Navbar /> - <SettingsForm user={session.user} /> - </div> - ) + await dbConnect() + const user = await User.findOne({ email: session.user.email }).lean() as any + + if (!user) { + redirect("/login") + } + + // Sanitize user object for client component + const initialUser = { + name: user.name, + email: user.email, + image: user.profileImage?.url || null, + twoFactorEnabled: !!user.twoFactorEnabled, + } + + return <SettingsContent initialUser={initialUser} /> } diff --git a/src/app/settings/settings-content.tsx b/src/app/settings/settings-content.tsx new file mode 100644 index 0000000..8916b9e --- /dev/null +++ b/src/app/settings/settings-content.tsx @@ -0,0 +1,537 @@ +"use client" + +import { useState } from "react" +import { useSession } from "next-auth/react" +import { useRouter } from "next/navigation" +import { Shield, Loader2, Copy } from "lucide-react" +import { toast } from "sonner" +import Image from "next/image" + +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Separator } from "@/components/ui/separator" +import Navbar from "@/components/Navbar" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Camera, Lock, Save, Trash2, Upload, User } from "lucide-react" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { updateProfileSchema, updatePasswordSchema, type UpdateProfileInput } from "@/lib/validation" +import { z } from "zod" + +// Re-using existing types and schemas from previous implementation +const passwordChangeSchema = updatePasswordSchema.extend({ + confirmPassword: z.string() +}).refine((data) => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], +}) + +type ProfileFormData = UpdateProfileInput +type PasswordFormData = z.infer<typeof passwordChangeSchema> + +interface SettingsContentProps { + initialUser: { + name: string + email: string + image: string | null + twoFactorEnabled: boolean + } +} + +export default function SettingsContent({ initialUser }: SettingsContentProps) { + const { update } = useSession() + const router = useRouter() + const [twoFactorEnabled, setTwoFactorEnabled] = useState(initialUser.twoFactorEnabled) + const [is2FALoading, setIs2FALoading] = useState(false) + const [setupData, setSetupData] = useState<{ secret: string;qrCode: string } | null>(null) + const [verificationCode, setVerificationCode] = useState("") + const [isDialogOpen, setIsDialogOpen] = useState(false) + + // Existing state for other forms + const [isLoading, setIsLoading] = useState(false) + const [isImageLoading, setIsImageLoading] = useState(false) + const [profileImageUrl, setProfileImageUrl] = useState<string | null>(initialUser.image) + + const profileForm = useForm<ProfileFormData>({ + resolver: zodResolver(updateProfileSchema), + defaultValues: { + name: initialUser.name, + email: initialUser.email, + }, + }) + + const passwordForm = useForm<PasswordFormData>({ + resolver: zodResolver(passwordChangeSchema), + defaultValues: { + currentPassword: "", + newPassword: "", + confirmPassword: "", + }, + }) + + // 2FA Handlers + const start2FASetup = async () => { + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { method: "PUT" }) + const data = await res.json() + if (data.error) throw new Error(data.error) + setSetupData(data) + setIsDialogOpen(true) + } catch (error) { + toast.error("Failed to start 2FA setup") + } finally { + setIs2FALoading(false) + } + } + + const verifyAndEnable2FA = async () => { + if (verificationCode.length !== 6) { + toast.error("Please enter a 6-digit code") + return + } + + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code: verificationCode, + secret: setupData?.secret, + }), + }) + const data = await res.json() + + if (data.error) throw new Error(data.error) + + setTwoFactorEnabled(true) + setIsDialogOpen(false) + toast.success("Two-factor authentication enabled") + router.refresh() + } catch (error) { + toast.error("Invalid verification code") + } finally { + setIs2FALoading(false) + setVerificationCode("") + } + } + + const disable2FA = async () => { + if (!confirm("Are you sure you want to disable 2FA? This will make your account less secure.")) return + + setIs2FALoading(true) + try { + const res = await fetch("/api/user/2fa", { method: "DELETE" }) + const data = await res.json() + + if (data.error) throw new Error(data.error) + + setTwoFactorEnabled(false) + toast.success("Two-factor authentication disabled") + router.refresh() + } catch (error) { + toast.error("Failed to disable 2FA") + } finally { + setIs2FALoading(false) + } + } + + const copyToClipboard = () => { + if (setupData?.secret) { + navigator.clipboard.writeText(setupData.secret) + toast.success("Secret copied to clipboard") + } + } + + // Existing Handlers (Profile, Password, Image) + const onProfileSubmit = async (data: ProfileFormData) => { + setIsLoading(true) + try { + const response = await fetch("/api/user/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || "Failed to update profile") + return + } + await update({ name: data.name, email: data.email }) + toast.success("Profile updated successfully!") + } catch { + toast.error("An unexpected error occurred") + } finally { + setIsLoading(false) + } + } + + const onPasswordSubmit = async (data: PasswordFormData) => { + setIsLoading(true) + try { + const response = await fetch("/api/user/password", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }), + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || "Failed to update password") + return + } + toast.success("Password updated successfully!") + passwordForm.reset() + } catch { + toast.error("An unexpected error occurred") + } finally { + setIsLoading(false) + } + } + + const handleImageUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (!file) return + setIsImageLoading(true) + try { + const formData = new FormData() + formData.append('image', file) + const response = await fetch('/api/user/profile-image', { + method: 'POST', + body: formData, + }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || 'Failed to upload image') + return + } + setProfileImageUrl(result.profileImage.url) + toast.success('Profile image uploaded successfully!') + await update({ image: result.profileImage.url }) + } catch { + toast.error('An unexpected error occurred') + } finally { + setIsImageLoading(false) + } + } + + const handleImageDelete = async () => { + setIsImageLoading(true) + try { + const response = await fetch('/api/user/profile-image', { method: 'DELETE' }) + const result = await response.json() + if (!response.ok) { + toast.error(result.error || 'Failed to delete image') + return + } + setProfileImageUrl(null) + toast.success('Profile image deleted successfully!') + await update({ image: null }) + } catch { + toast.error('An unexpected error occurred') + } finally { + setIsImageLoading(false) + } + } + + return ( + <div className="min-h-screen bg-background"> + <Navbar /> + + <div className="container mx-auto px-4 py-8 max-w-2xl"> + <div className="space-y-6"> + <div> + <h1 className="text-3xl font-bold">Account Settings</h1> + <p className="text-muted-foreground"> + Manage your account information and security settings + </p> + </div> + + {/* Profile Information */} + <Card> + <CardHeader> + <CardTitle className="flex items-center"> + <User className="mr-2 h-5 w-5" /> + Profile Information + </CardTitle> + <CardDescription>Update your personal information</CardDescription> + </CardHeader> + <CardContent> + <Form {...profileForm}> + <form onSubmit={profileForm.handleSubmit(onProfileSubmit)} className="space-y-4"> + <FormField + control={profileForm.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>Full Name</FormLabel> + <FormControl> + <Input placeholder="John Doe" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={profileForm.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email Address</FormLabel> + <FormControl> + <Input type="email" placeholder="john@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <Button type="submit" disabled={isLoading}> + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + Save Changes + </Button> + </form> + </Form> + </CardContent> + </Card> + + <Separator /> + + {/* Profile Image */} + <Card> + <CardHeader> + <CardTitle className="flex items-center"> + <Camera className="mr-2 h-5 w-5" /> + Profile Image + </CardTitle> + <CardDescription>Upload or update your profile picture</CardDescription> + </CardHeader> + <CardContent> + <div className="flex items-center space-x-6"> + <Avatar className="h-24 w-24"> + <AvatarImage src={profileImageUrl || undefined} alt="Profile" /> + <AvatarFallback className="text-lg"> + {initialUser.name.charAt(0).toUpperCase()} + </AvatarFallback> + </Avatar> + + <div className="flex-1 space-y-3"> + <div className="flex items-center space-x-3"> + <div className="relative"> + <Button + disabled={isImageLoading} + variant="outline" + className="relative" + > + {isImageLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Upload className="mr-2 h-4 w-4" /> + {profileImageUrl ? 'Change Image' : 'Upload Image'} + <input + type="file" + accept="image/jpeg,image/jpg,image/png,image/webp,image/gif" + onChange={handleImageUpload} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + </Button> + </div> + + {profileImageUrl && ( + <Button + onClick={handleImageDelete} + disabled={isImageLoading} + variant="outline" + className="text-destructive hover:text-destructive" + > + {isImageLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Trash2 className="mr-2 h-4 w-4" /> + Remove + </Button> + )} + </div> + <p className="text-xs text-muted-foreground"> + Supported formats: JPEG, PNG, WebP, GIF. Maximum size: 10MB. + </p> + </div> + </div> + </CardContent> + </Card> + + <Separator /> + + {/* Two-Factor Authentication */} + <Card> + <CardHeader> + <CardTitle className="flex items-center"> + <Shield className="mr-2 h-5 w-5" /> + Two-Factor Authentication + </CardTitle> + <CardDescription> + Add an extra layer of security to your account + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <p className="font-medium">Status: {twoFactorEnabled ? "Enabled" : "Disabled"}</p> + <p className="text-sm text-muted-foreground"> + {twoFactorEnabled + ? "Your account is secured with 2FA." + : "Protect your account by enabling 2FA."} + </p> + </div> + {twoFactorEnabled ? ( + <Button + variant="destructive" + onClick={disable2FA} + disabled={is2FALoading} + > + {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Disable 2FA + </Button> + ) : ( + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogTrigger asChild> + <Button onClick={start2FASetup} disabled={is2FALoading}> + {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Enable 2FA + </Button> + </DialogTrigger> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Set up Two-Factor Authentication</DialogTitle> + <DialogDescription> + Scan the QR code with your authenticator app (like Google Authenticator or Authy). + </DialogDescription> + </DialogHeader> + + {setupData && ( + <div className="flex flex-col items-center space-y-4 py-4"> + <div className="relative w-48 h-48"> + <Image + src={setupData.qrCode} + alt="2FA QR Code" + fill + style={{ objectFit: "contain" }} + /> + </div> + + <div className="flex items-center space-x-2"> + <code className="bg-muted px-2 py-1 rounded text-sm"> + {setupData.secret} + </code> + <Button size="icon" variant="ghost" onClick={copyToClipboard}> + <Copy className="h-4 w-4" /> + </Button> + </div> + + <div className="w-full space-y-2"> + <Label htmlFor="code">Verification Code</Label> + <Input + id="code" + placeholder="Enter 6-digit code" + value={verificationCode} + onChange={(e) => setVerificationCode(e.target.value.slice(0, 6))} + maxLength={6} + /> + </div> + + <Button + className="w-full" + onClick={verifyAndEnable2FA} + disabled={verificationCode.length !== 6 || is2FALoading} + > + {is2FALoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Verify & Enable + </Button> + </div> + )} + </DialogContent> + </Dialog> + )} + </div> + </CardContent> + </Card> + + <Separator /> + + {/* Password Change */} + <Card> + <CardHeader> + <CardTitle className="flex items-center"> + <Lock className="mr-2 h-5 w-5" /> + Change Password + </CardTitle> + <CardDescription> + Update your password to keep your account secure + </CardDescription> + </CardHeader> + <CardContent> + <Form {...passwordForm}> + <form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4"> + <FormField + control={passwordForm.control} + name="currentPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Current Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Enter current password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={passwordForm.control} + name="newPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>New Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Enter new password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={passwordForm.control} + name="confirmPassword" + render={({ field }) => ( + <FormItem> + <FormLabel>Confirm New Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Confirm new password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <Button type="submit" disabled={isLoading}> + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> + Update Password + </Button> + </form> + </Form> + </CardContent> + </Card> + </div> + </div> + </div> + ) +} diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..a6f1cfb --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Root>) { + return <DialogPrimitive.Root data-slot="dialog" {...props} /> +} + +function DialogTrigger({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { + return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> +} + +function DialogPortal({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Portal>) { + return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> +} + +function DialogClose({ + ...props +}: React.ComponentProps<typeof DialogPrimitive.Close>) { + return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { + return ( + <DialogPrimitive.Overlay + data-slot="dialog-overlay" + className={cn( + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", + className + )} + {...props} + /> + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Content> & { + showCloseButton?: boolean +}) { + return ( + <DialogPortal data-slot="dialog-portal"> + <DialogOverlay /> + <DialogPrimitive.Content + data-slot="dialog-content" + className={cn( + "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg", + className + )} + {...props} + > + {children} + {showCloseButton && ( + <DialogPrimitive.Close + data-slot="dialog-close" + className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" + > + <XIcon /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + )} + </DialogPrimitive.Content> + </DialogPortal> + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-header" + className={cn("flex flex-col gap-2 text-center sm:text-left", className)} + {...props} + /> + ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="dialog-footer" + className={cn( + "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", + className + )} + {...props} + /> + ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Title>) { + return ( + <DialogPrimitive.Title + data-slot="dialog-title" + className={cn("text-lg leading-none font-semibold", className)} + {...props} + /> + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps<typeof DialogPrimitive.Description>) { + return ( + <DialogPrimitive.Description + data-slot="dialog-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0ed9d12..ad47d5f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,6 +1,7 @@ import { type NextAuthOptions } from "next-auth" import CredentialsProvider from "next-auth/providers/credentials" import bcrypt from "bcryptjs" +import { authenticator } from "otplib" import dbConnect from "./mongodb" import User from "@/model/User" import { loginSchema } from "./validation" @@ -11,7 +12,8 @@ export const authOptions: NextAuthOptions = { name: "credentials", credentials: { email: { label: "Email", type: "email" }, - password: { label: "Password", type: "password" } + password: { label: "Password", type: "password" }, + twoFactorCode: { label: "2FA Code", type: "text" } }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) return null @@ -19,7 +21,7 @@ export const authOptions: NextAuthOptions = { const result = loginSchema.safeParse(credentials) if (!result.success) return null - const { email, password } = result.data + const { email, password, twoFactorCode } = result.data try { await dbConnect() @@ -30,6 +32,17 @@ export const authOptions: NextAuthOptions = { const isPasswordValid = await bcrypt.compare(password, user.password) if (!isPasswordValid) return null + if (user.twoFactorEnabled) { + if (!twoFactorCode) { + throw new Error("2FA_REQUIRED") + } + + const isValid = authenticator.check(twoFactorCode, user.twoFactorSecret) + if (!isValid) { + throw new Error("Invalid 2FA Code") + } + } + return { id: user._id.toString(), email: user.email, @@ -38,6 +51,10 @@ export const authOptions: NextAuthOptions = { } } catch (error) { console.error("Auth error:", error) + // Rethrow specific 2FA errors so they reach the client + if (error instanceof Error && (error.message === "2FA_REQUIRED" || error.message === "Invalid 2FA Code")) { + throw error + } return null } } diff --git a/src/lib/validation.ts b/src/lib/validation.ts index ab9416e..bc5a440 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -30,7 +30,11 @@ export const loginSchema = z.object({ .max(254, 'Email must be at most 254 characters'), password: z .string() - .max(128, 'Password must be at most 128 characters') + .max(128, 'Password must be at most 128 characters'), + twoFactorCode: z + .string() + .length(6, 'Code must be 6 digits') + .optional() }) // Profile update schema (reusing name and email from registerSchema) diff --git a/src/model/User.ts b/src/model/User.ts index e1784f2..c5c81de 100644 --- a/src/model/User.ts +++ b/src/model/User.ts @@ -8,7 +8,9 @@ const UserSchema = new Schema({ url: { type: String }, key: { type: String }, uploadedAt: { type: Date } - } + }, + twoFactorEnabled: { type: Boolean, default: false }, + twoFactorSecret: { type: String } }, { timestamps: true }); @@ -16,6 +18,7 @@ const UserSchema = new Schema({ UserSchema.set('toJSON', { transform: (_doc: Document, ret: Record<string, unknown>) => { delete ret.password; + delete ret.twoFactorSecret; delete ret.__v; return ret; } |
