Add TOTP 2FA and retroactive rule application
- TOTP-based two-factor authentication (RFC 6238, HMAC-SHA1): setup, enable, disable in Settings → Password & Security - Login flow: password verified first, then temp token issued for 5-min TOTP window before full session is granted - QR code generated server-side via `qrcode` package for scanning with any authenticator app - "Run Rules Now" button in TransactionRulesManager applies auto-categorization rules to existing uncategorized expenses Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
5339e9818a
commit
52293d7db3
15 files changed
+877
-90
No files matched your search
Generated
+274
-6
@@ -9,8 +9,10 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.1.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
@@ -1521,12 +1523,20 @@
|
||||
"version": "20.19.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
|
||||
"integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@@ -2157,11 +2167,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"
|
||||
@@ -2556,6 +2574,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.30001751",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
|
||||
@@ -2599,11 +2626,21 @@
|
||||
"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/color-convert": {
|
||||
"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"
|
||||
@@ -2616,7 +2653,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": {
|
||||
@@ -2734,6 +2770,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2787,6 +2832,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -3669,6 +3720,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",
|
||||
@@ -4156,6 +4216,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",
|
||||
@@ -5257,6 +5326,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",
|
||||
@@ -5274,7 +5352,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"
|
||||
@@ -5316,6 +5393,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",
|
||||
@@ -5387,6 +5473,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/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5480,6 +5583,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",
|
||||
@@ -5627,6 +5745,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",
|
||||
@@ -5861,6 +5985,26 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -5974,6 +6118,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",
|
||||
@@ -6325,7 +6481,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
@@ -6487,6 +6642,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",
|
||||
@@ -6519,6 +6680,26 @@
|
||||
"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/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",
|
||||
@@ -6526,6 +6707,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",
|
||||
|
||||
@@ -10,8 +10,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"next": "16.1.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const session = await FileStorageService.validateSession(token);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { code } = await request.json();
|
||||
if (!code) return NextResponse.json({ error: 'Code required' }, { status: 400 });
|
||||
|
||||
await FileStorageService.disable2FA(session.userId, code);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Failed to disable 2FA' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { tempToken, code } = await request.json();
|
||||
if (!tempToken || !code) return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
|
||||
const result = await FileStorageService.completeTOTPLogin(tempToken, code);
|
||||
return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || '2FA login failed' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const session = await FileStorageService.validateSession(token);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { secret, otpAuthUrl } = await FileStorageService.setup2FA(session.userId);
|
||||
const qrDataUrl = await QRCode.toDataURL(otpAuthUrl, { width: 200, margin: 2 });
|
||||
|
||||
return NextResponse.json({ secret, qrDataUrl });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Setup failed' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const session = await FileStorageService.validateSession(token);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const enabled = await FileStorageService.get2FAStatus(session.userId);
|
||||
return NextResponse.json({ enabled });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { FileStorageService } from '@/lib/fileStorage';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = request.headers.get('x-session-token');
|
||||
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const session = await FileStorageService.validateSession(token);
|
||||
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { code } = await request.json();
|
||||
if (!code) return NextResponse.json({ error: 'Code required' }, { status: 400 });
|
||||
|
||||
await FileStorageService.enable2FA(session.userId, code);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Verification failed' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const result = await FileStorageService.loginUser(email, password);
|
||||
|
||||
if ('requiresTOTP' in result && result.requiresTOTP) {
|
||||
return NextResponse.json({ requiresTOTP: true, tempToken: result.tempToken });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, user: { userId: result.userId, email: result.email }, sessionToken: result.sessionToken });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message || 'Login failed' }, { status: 401 });
|
||||
|
||||
@@ -1160,6 +1160,11 @@ export default function BudgetDashboard() {
|
||||
rules={transactionRules}
|
||||
envelopes={envelopes}
|
||||
onRulesChange={setTransactionRules}
|
||||
transactions={transactions}
|
||||
onApplyToExisting={updated => {
|
||||
setTransactions(updated as any);
|
||||
setEnvelopes(calculateEnvelopeSpending(envelopes, updated as any));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,11 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 2FA step
|
||||
const [totpStep, setTotpStep] = useState(false);
|
||||
const [tempToken, setTempToken] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -27,24 +32,41 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
|
||||
if (isLogin) {
|
||||
const user = await AuthService.signIn(email, password);
|
||||
DataService.setUserId(user.userId);
|
||||
onAuthSuccess();
|
||||
onClose();
|
||||
} else {
|
||||
if (password !== confirmPassword) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
if (password !== confirmPassword) throw new Error('Passwords do not match');
|
||||
const user = await AuthService.signUp(email, password);
|
||||
DataService.setUserId(user.userId);
|
||||
onAuthSuccess();
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.requiresTOTP) {
|
||||
setTempToken(err.tempToken);
|
||||
setTotpStep(true);
|
||||
} else {
|
||||
let msg = err.message;
|
||||
if (msg === 'Invalid credentials') msg = 'The email or password you entered is incorrect.';
|
||||
else if (msg === 'User already exists') msg = 'An account with this email already exists. Try signing in instead.';
|
||||
setError(msg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const user = await AuthService.signInWithTOTP(tempToken, totpCode.trim());
|
||||
DataService.setUserId(user.userId);
|
||||
onAuthSuccess();
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
// Provide more user-friendly error messages
|
||||
let errorMessage = error.message;
|
||||
if (error.message === 'Invalid credentials') {
|
||||
errorMessage = 'The email or password you entered is incorrect. Please try again.';
|
||||
} else if (error.message === 'User already exists') {
|
||||
errorMessage = 'An account with this email already exists. Please try logging in instead.';
|
||||
}
|
||||
setError(errorMessage);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '2FA verification failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -57,85 +79,83 @@ export default function AuthModal({ isOpen, onClose, onAuthSuccess }: AuthModalP
|
||||
<div className="bg-white rounded-lg p-8 max-w-md w-full shadow-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{isLogin ? 'Sign In' : 'Create Account'}
|
||||
{totpStep ? 'Two-Factor Authentication' : isLogin ? 'Sign In' : 'Create Account'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isLogin && (
|
||||
{totpStep ? (
|
||||
<form onSubmit={handleTOTPSubmit} className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||
🔐 Enter the 6-digit code from your authenticator app.
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Authentication Code</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={totpCode}
|
||||
onChange={e => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 text-center text-2xl tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={loading || totpCode.length !== 6}
|
||||
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: 'var(--color-primary-blue)' }}>
|
||||
{loading ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
<button type="button" onClick={() => { setTotpStep(false); setTotpCode(''); setError(''); }}
|
||||
className="w-full text-gray-600 hover:text-gray-800 text-sm">
|
||||
← Back to sign in
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required minLength={6} />
|
||||
</div>
|
||||
{!isLogin && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required minLength={6} />
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||
<button type="submit" disabled={loading}
|
||||
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 focus:outline-none focus:ring-2 disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: 'var(--color-primary-blue)' }}>
|
||||
{loading ? 'Please wait…' : isLogin ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full text-white py-2 px-4 rounded-md hover:opacity-90 focus:outline-none focus:ring-2 disabled:opacity-50 transition-opacity"
|
||||
style={{ backgroundColor: 'var(--color-primary-blue)' }}
|
||||
>
|
||||
{loading ? 'Please wait...' : (isLogin ? 'Sign In' : 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsLogin(!isLogin)}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
{!totpStep && (
|
||||
<div className="mt-4 text-center">
|
||||
<button onClick={() => setIsLogin(!isLogin)} className="text-blue-600 hover:text-blue-800 text-sm">
|
||||
{isLogin ? "Don't have an account? Sign up" : 'Already have an account? Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
-2
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AuthService } from '../lib/authService';
|
||||
import { DataService } from '../lib/dataService';
|
||||
|
||||
@@ -21,6 +21,87 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 2FA state
|
||||
const [twoFAEnabled, setTwoFAEnabled] = useState(false);
|
||||
const [twoFASetupData, setTwoFASetupData] = useState<{ secret: string; qrDataUrl: string } | null>(null);
|
||||
const [twoFACode, setTwoFACode] = useState('');
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [twoFALoading, setTwoFALoading] = useState(false);
|
||||
const [twoFAMessage, setTwoFAMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = AuthService.getSessionToken();
|
||||
if (!token) return;
|
||||
fetch('/api/auth/2fa/status', { headers: { 'x-session-token': token } })
|
||||
.then(r => r.json())
|
||||
.then(d => setTwoFAEnabled(d.enabled ?? false))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSetup2FA = async () => {
|
||||
setTwoFALoading(true);
|
||||
setTwoFAMessage(null);
|
||||
try {
|
||||
const token = AuthService.getSessionToken();
|
||||
const res = await fetch('/api/auth/2fa/setup', { method: 'POST', headers: { 'x-session-token': token ?? '' } });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setTwoFASetupData({ secret: data.secret, qrDataUrl: data.qrDataUrl });
|
||||
setTwoFACode('');
|
||||
} catch (e: any) {
|
||||
setTwoFAMessage({ type: 'error', text: e.message });
|
||||
} finally {
|
||||
setTwoFALoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTwoFALoading(true);
|
||||
setTwoFAMessage(null);
|
||||
try {
|
||||
const token = AuthService.getSessionToken();
|
||||
const res = await fetch('/api/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
|
||||
body: JSON.stringify({ code: twoFACode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setTwoFAEnabled(true);
|
||||
setTwoFASetupData(null);
|
||||
setTwoFACode('');
|
||||
setTwoFAMessage({ type: 'success', text: '2FA enabled successfully!' });
|
||||
} catch (e: any) {
|
||||
setTwoFAMessage({ type: 'error', text: e.message });
|
||||
} finally {
|
||||
setTwoFALoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setTwoFALoading(true);
|
||||
setTwoFAMessage(null);
|
||||
try {
|
||||
const token = AuthService.getSessionToken();
|
||||
const res = await fetch('/api/auth/2fa/disable', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-session-token': token ?? '' },
|
||||
body: JSON.stringify({ code: disableCode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setTwoFAEnabled(false);
|
||||
setDisableCode('');
|
||||
setTwoFAMessage({ type: 'success', text: '2FA disabled.' });
|
||||
} catch (e: any) {
|
||||
setTwoFAMessage({ type: 'error', text: e.message });
|
||||
} finally {
|
||||
setTwoFALoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
@@ -223,7 +304,8 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
||||
|
||||
{/* Password Tab */}
|
||||
{activeTab === 'password' && (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6 max-w-md">
|
||||
<div className="space-y-6 max-w-md">
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Change Password</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Signed in as <strong>{user.email}</strong></p>
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
@@ -251,6 +333,83 @@ export default function Settings({ user, onAccountDeleted }: SettingsProps) {
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* 2FA Section */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Two-Factor Authentication</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{twoFAEnabled ? '🔐 2FA is enabled — your account is protected.' : 'Add an extra layer of security with an authenticator app.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold px-2 py-1 rounded-full ${twoFAEnabled ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{twoFAEnabled ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{twoFAMessage && (
|
||||
<div className={`mb-4 p-3 rounded-lg text-sm ${twoFAMessage.type === 'success' ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
|
||||
{twoFAMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!twoFAEnabled && !twoFASetupData && (
|
||||
<button onClick={handleSetup2FA} disabled={twoFALoading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 transition-opacity text-sm">
|
||||
{twoFALoading ? 'Generating…' : 'Enable 2FA'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!twoFAEnabled && twoFASetupData && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">Scan this QR code with <strong>Google Authenticator</strong>, <strong>Authy</strong>, or any TOTP app:</p>
|
||||
<div className="flex justify-center">
|
||||
<img src={twoFASetupData.qrDataUrl} alt="2FA QR Code" className="w-48 h-48 border border-gray-200 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Or enter this code manually:</p>
|
||||
<code className="block bg-gray-100 px-3 py-2 rounded text-sm font-mono break-all text-gray-800 select-all">{twoFASetupData.secret}</code>
|
||||
</div>
|
||||
<form onSubmit={handleVerify2FA} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Enter the 6-digit code to confirm</label>
|
||||
<input type="text" inputMode="numeric"
|
||||
value={twoFACode}
|
||||
onChange={e => setTwoFACode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-center text-xl tracking-widest focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white text-gray-900"
|
||||
required />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { setTwoFASetupData(null); setTwoFACode(''); }}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 text-sm">Cancel</button>
|
||||
<button type="submit" disabled={twoFALoading || twoFACode.length !== 6}
|
||||
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm">
|
||||
{twoFALoading ? 'Verifying…' : 'Verify & Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{twoFAEnabled && (
|
||||
<form onSubmit={handleDisable2FA} className="space-y-3">
|
||||
<p className="text-sm text-gray-600">Enter your current 2FA code to disable:</p>
|
||||
<input type="text" inputMode="numeric"
|
||||
value={disableCode}
|
||||
onChange={e => setDisableCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-center text-xl tracking-widest focus:outline-none focus:ring-2 focus:ring-red-400 bg-white text-gray-900"
|
||||
required />
|
||||
<button type="submit" disabled={twoFALoading || disableCode.length !== 6}
|
||||
className="w-full bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 disabled:opacity-50 text-sm">
|
||||
{twoFALoading ? 'Disabling…' : 'Disable 2FA'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export Tab */}
|
||||
|
||||
@@ -5,17 +5,22 @@ import type { TransactionRule } from '../app/page';
|
||||
|
||||
interface Envelope { id: string; name: string; color: string }
|
||||
|
||||
interface Transaction { id: string; description: string; amount: number; envelopeId?: string }
|
||||
|
||||
interface Props {
|
||||
rules: TransactionRule[];
|
||||
envelopes: Envelope[];
|
||||
onRulesChange: (rules: TransactionRule[]) => void;
|
||||
transactions?: Transaction[];
|
||||
onApplyToExisting?: (updated: Transaction[]) => void;
|
||||
}
|
||||
|
||||
export default function TransactionRulesManager({ rules, envelopes, onRulesChange }: Props) {
|
||||
export default function TransactionRulesManager({ rules, envelopes, onRulesChange, transactions = [], onApplyToExisting }: Props) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [envelopeId, setEnvelopeId] = useState(envelopes[0]?.id ?? '');
|
||||
const [matchCase, setMatchCase] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [applyResult, setApplyResult] = useState<string | null>(null);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!keyword.trim()) { setError('Keyword is required.'); return; }
|
||||
@@ -26,6 +31,26 @@ export default function TransactionRulesManager({ rules, envelopes, onRulesChang
|
||||
|
||||
const handleDelete = (id: string) => onRulesChange(rules.filter(r => r.id !== id));
|
||||
|
||||
const handleApplyToExisting = () => {
|
||||
if (!onApplyToExisting || rules.length === 0) return;
|
||||
let matched = 0;
|
||||
const updated = transactions.map(tx => {
|
||||
if (tx.envelopeId || tx.amount >= 0) return tx;
|
||||
for (const rule of rules) {
|
||||
const haystack = rule.matchCase ? tx.description : tx.description.toLowerCase();
|
||||
const needle = rule.matchCase ? rule.keyword : rule.keyword.toLowerCase();
|
||||
if (haystack.includes(needle) && envelopes.some(e => e.id === rule.envelopeId)) {
|
||||
matched++;
|
||||
return { ...tx, envelopeId: rule.envelopeId };
|
||||
}
|
||||
}
|
||||
return tx;
|
||||
});
|
||||
onApplyToExisting(updated);
|
||||
setApplyResult(`Applied rules: ${matched} transaction${matched !== 1 ? 's' : ''} categorized.`);
|
||||
setTimeout(() => setApplyResult(null), 4000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
@@ -107,6 +132,27 @@ export default function TransactionRulesManager({ rules, envelopes, onRulesChang
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onApplyToExisting && rules.length > 0 && (
|
||||
<div className="mt-4 bg-white shadow rounded-lg p-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 text-sm">Apply rules to existing transactions</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Categorize uncategorized expenses already in your history using the current rules.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleApplyToExisting}
|
||||
className="flex-shrink-0 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 text-sm font-medium whitespace-nowrap">
|
||||
Run Rules Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{applyResult && (
|
||||
<div className="mt-3 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800">
|
||||
✓ {applyResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg text-sm text-blue-800">
|
||||
<strong>How rules work:</strong> When you add a new expense, its description is checked against all rules in order.
|
||||
The first matching rule wins. Rules only apply to expenses (negative amounts) without an already-assigned envelope.
|
||||
|
||||
@@ -40,6 +40,32 @@ export class AuthService {
|
||||
throw new Error(error.error || 'Login failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.requiresTOTP) {
|
||||
const err: any = new Error('2FA required');
|
||||
err.requiresTOTP = true;
|
||||
err.tempToken = result.tempToken;
|
||||
throw err;
|
||||
}
|
||||
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
static async signInWithTOTP(tempToken: string, code: string): Promise<User> {
|
||||
const response = await fetch('/api/auth/2fa/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tempToken, code }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || '2FA verification failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
this._persist(result.user, result.sessionToken);
|
||||
this.notifyAuthChange(result.user);
|
||||
|
||||
+103
-3
@@ -2,12 +2,14 @@ import path from 'path';
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { encryptData, decryptData } from '@/lib/crypto';
|
||||
import { generateSecret, verifyTOTP } from '@/lib/totp';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const USERS_FILE = path.join(DATA_DIR, 'users.json');
|
||||
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
||||
const RESET_TOKENS_FILE = path.join(DATA_DIR, 'reset_tokens.json');
|
||||
const SHARES_FILE = path.join(DATA_DIR, 'shares.json');
|
||||
const TEMP_TOKENS_FILE = path.join(DATA_DIR, 'temp_tokens.json');
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || 'default-key-change-in-production';
|
||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
@@ -27,7 +29,7 @@ interface Shares { [shareId: string]: Share }
|
||||
async function initializeDirectories() {
|
||||
try {
|
||||
await fsPromises.mkdir(DATA_DIR, { recursive: true });
|
||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE]) {
|
||||
for (const file of [USERS_FILE, SESSIONS_FILE, RESET_TOKENS_FILE, SHARES_FILE, TEMP_TOKENS_FILE]) {
|
||||
try {
|
||||
await fsPromises.access(file);
|
||||
} catch {
|
||||
@@ -45,8 +47,14 @@ interface UserCredentials {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
userId: string;
|
||||
totpSecret?: string;
|
||||
totpEnabled?: boolean;
|
||||
pendingTotpSecret?: string;
|
||||
}
|
||||
|
||||
interface TempToken { userId: string; email: string; expiresAt: string }
|
||||
interface TempTokens { [token: string]: TempToken }
|
||||
|
||||
interface Users {
|
||||
[email: string]: UserCredentials;
|
||||
}
|
||||
@@ -133,20 +141,112 @@ export class FileStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
static async loginUser(email: string, password: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
static async loginUser(email: string, password: string): Promise<
|
||||
| { userId: string; email: string; sessionToken: string; requiresTOTP?: false }
|
||||
| { requiresTOTP: true; tempToken: string }
|
||||
> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = users[email];
|
||||
|
||||
// Always run bcrypt compare to prevent timing attacks
|
||||
const hashToCompare = user?.passwordHash ?? '$2a$12$invalidhashpadding000000000000000000000000000000000000000';
|
||||
const valid = await bcrypt.compare(password, hashToCompare);
|
||||
|
||||
if (!user || !valid) throw new Error('Invalid credentials');
|
||||
|
||||
if (user.totpEnabled && user.totpSecret) {
|
||||
const releaseLock = await FileLock.acquire('temp-tokens');
|
||||
try {
|
||||
const temps = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
const tempToken = crypto.randomUUID();
|
||||
temps[tempToken] = { userId: user.userId, email: user.email, expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString() };
|
||||
await writeJson(TEMP_TOKENS_FILE, temps);
|
||||
return { requiresTOTP: true, tempToken };
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
const sessionToken = await this._createSession(user.userId, email);
|
||||
return { userId: user.userId, email: user.email, sessionToken };
|
||||
}
|
||||
|
||||
static async completeTOTPLogin(tempToken: string, totpCode: string): Promise<{ userId: string; email: string; sessionToken: string }> {
|
||||
const temps = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
const record = temps[tempToken];
|
||||
if (!record) throw new Error('Invalid or expired token');
|
||||
if (new Date(record.expiresAt) < new Date()) throw new Error('Token expired — please sign in again');
|
||||
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = Object.values(users).find(u => u.userId === record.userId);
|
||||
if (!user?.totpSecret) throw new Error('2FA not configured');
|
||||
|
||||
if (!verifyTOTP(user.totpSecret, totpCode)) throw new Error('Invalid 2FA code');
|
||||
|
||||
const releaseLock = await FileLock.acquire('temp-tokens');
|
||||
try {
|
||||
const temps2 = await readJson<TempTokens>(TEMP_TOKENS_FILE);
|
||||
delete temps2[tempToken];
|
||||
await writeJson(TEMP_TOKENS_FILE, temps2);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
|
||||
const sessionToken = await this._createSession(record.userId, record.email);
|
||||
return { userId: record.userId, email: record.email, sessionToken };
|
||||
}
|
||||
|
||||
static async setup2FA(userId: string): Promise<{ secret: string; otpAuthUrl: string }> {
|
||||
const { getOtpAuthUrl } = await import('@/lib/totp');
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const userEntry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!userEntry) throw new Error('User not found');
|
||||
|
||||
const secret = generateSecret();
|
||||
const [email, user] = userEntry;
|
||||
users[email] = { ...user, pendingTotpSecret: secret };
|
||||
await writeJson(USERS_FILE, users);
|
||||
|
||||
return { secret, otpAuthUrl: getOtpAuthUrl(secret, email) };
|
||||
}
|
||||
|
||||
static async enable2FA(userId: string, token: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
try {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const entry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!entry) throw new Error('User not found');
|
||||
const [email, user] = entry;
|
||||
if (!user.pendingTotpSecret) throw new Error('No pending 2FA setup — start setup first');
|
||||
if (!verifyTOTP(user.pendingTotpSecret, token)) throw new Error('Invalid code — please check your authenticator app');
|
||||
users[email] = { ...user, totpSecret: user.pendingTotpSecret, totpEnabled: true, pendingTotpSecret: undefined };
|
||||
await writeJson(USERS_FILE, users);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async disable2FA(userId: string, token: string): Promise<void> {
|
||||
const releaseLock = await FileLock.acquire('users-file');
|
||||
try {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const entry = Object.entries(users).find(([, u]) => u.userId === userId);
|
||||
if (!entry) throw new Error('User not found');
|
||||
const [email, user] = entry;
|
||||
if (!user.totpEnabled || !user.totpSecret) throw new Error('2FA is not enabled');
|
||||
if (!verifyTOTP(user.totpSecret, token)) throw new Error('Invalid 2FA code');
|
||||
users[email] = { ...user, totpSecret: undefined, totpEnabled: false, pendingTotpSecret: undefined };
|
||||
await writeJson(USERS_FILE, users);
|
||||
} finally {
|
||||
releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
static async get2FAStatus(userId: string): Promise<boolean> {
|
||||
const users = await readJson<Users>(USERS_FILE);
|
||||
const user = Object.values(users).find(u => u.userId === userId);
|
||||
return user?.totpEnabled ?? false;
|
||||
}
|
||||
|
||||
static async validateSession(token: string): Promise<{ userId: string; email: string } | null> {
|
||||
try {
|
||||
const sessions = await readJson<Sessions>(SESSIONS_FILE);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
function base32Encode(buf: Buffer): string {
|
||||
let result = '';
|
||||
let bits = 0;
|
||||
let current = 0;
|
||||
for (const byte of buf) {
|
||||
current = (current << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
result += BASE32_CHARS[(current >> (bits - 5)) & 0x1f];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) result += BASE32_CHARS[(current << (5 - bits)) & 0x1f];
|
||||
return result;
|
||||
}
|
||||
|
||||
function base32Decode(s: string): Buffer {
|
||||
const bytes: number[] = [];
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
for (const ch of s.toUpperCase().replace(/=+$/, '')) {
|
||||
const idx = BASE32_CHARS.indexOf(ch);
|
||||
if (idx < 0) continue;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bytes.push((value >> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
export function generateSecret(): string {
|
||||
return base32Encode(crypto.randomBytes(20));
|
||||
}
|
||||
|
||||
export function getOtpAuthUrl(secret: string, email: string, issuer = 'Capsule'): string {
|
||||
const label = encodeURIComponent(`${issuer}:${email}`);
|
||||
return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
|
||||
function computeTOTP(key: Buffer, counter: number): string {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(BigInt(counter));
|
||||
const hmac = crypto.createHmac('sha1', key);
|
||||
hmac.update(buf);
|
||||
const digest = hmac.digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const code = (
|
||||
((digest[offset] & 0x7f) << 24) |
|
||||
((digest[offset + 1] & 0xff) << 16) |
|
||||
((digest[offset + 2] & 0xff) << 8) |
|
||||
(digest[offset + 3] & 0xff)
|
||||
) % 1_000_000;
|
||||
return code.toString().padStart(6, '0');
|
||||
}
|
||||
|
||||
export function verifyTOTP(secret: string, token: string): boolean {
|
||||
const key = base32Decode(secret);
|
||||
const step = Math.floor(Date.now() / 1000 / 30);
|
||||
for (const w of [-1, 0, 1]) {
|
||||
if (computeTOTP(key, step + w) === token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
Reference in new issue
Block a user