From 6dd6fffb93582cfa7aaa3dc4fd098bbc4444e550 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 16 Jan 2026 13:44:33 +0800 Subject: [PATCH 01/14] feat: add protobuf support and update auth flow - Add @bufbuild/protobuf, protobufjs, decimal.js, long, and ts-proto dependencies for protobuf functionality - Add @types/decimal.js as dev dependency - Introduce 'proto' script in package.json for proto generation - Replace direct API calls with useRegister hook in register page - Update useLogin hook usage in login page component - Modify login mutation parameters to use correct field names - Update mock user login to use UserLevelType enum values - Remove manual loading state in favor of mutation pending state - Adjust routing after registration to dashboard --- package-lock.json | 221 +- package.json | 9 +- scripts/generate-proto.js | 55 + src/app/register/page.tsx | 38 +- src/components/auth/LoginPage.tsx | 20 +- src/components/features/Accounts.tsx | 82 +- src/components/features/AddAccountModal.tsx | 54 +- src/components/features/BalanceTrendChart.tsx | 16 +- src/components/features/Dashboard.tsx | 18 +- src/components/features/EditAccountModal.tsx | 29 +- src/components/features/Transactions.tsx | 94 +- .../features/settings/CurrencySettings.tsx | 9 +- .../features/settings/DataExportSettings.tsx | 14 +- .../features/settings/MainSettings.tsx | 8 +- .../features/settings/Subscription.tsx | 5 +- .../features/settings/ThemeSettings.tsx | 5 +- src/context/GlobalContext.tsx | 11 +- src/lib/api.ts | 4 +- src/lib/crypto/browser-crypto.ts | 113 + src/lib/crypto/encoding.ts | 21 + src/lib/data.ts | 13 +- src/lib/hooks/useAccounts.test.tsx | 69 +- src/lib/hooks/useAccounts.ts | 47 +- src/lib/hooks/useAuth.test.tsx | 213 +- src/lib/hooks/useAuth.ts | 43 +- src/lib/hooks/useTransactions.test.tsx | 52 +- src/lib/hooks/useTransactions.ts | 55 +- src/lib/hooks/useWebSocket.ts | 2 +- src/lib/network/secure-client.ts | 378 ++++ src/lib/proto/account/v1/account.ts | 1810 +++++++++++++++++ src/lib/proto/auth/v1/auth.ts | 1407 +++++++++++++ src/lib/proto/base/base.ts | 862 ++++++++ src/lib/proto/config/v1/config.ts | 907 +++++++++ src/lib/proto/dashboard/v1/dashboard.ts | 870 ++++++++ src/lib/proto/data/v1/data.ts | 780 +++++++ src/lib/proto/google/protobuf/struct.ts | 591 ++++++ src/lib/proto/google/protobuf/timestamp.ts | 219 ++ src/lib/proto/health/v1/health.ts | 168 ++ src/lib/proto/hello/v1/hello.ts | 171 ++ src/lib/proto/task/v1/task.ts | 1181 +++++++++++ src/lib/proto/transaction/v1/transaction.ts | 1433 +++++++++++++ src/lib/proto/user/v1/user.ts | 836 ++++++++ src/lib/services/accountService.ts | 118 +- src/lib/services/authService.ts | 15 +- src/lib/services/dashboardService.ts | 17 +- src/lib/services/index.ts | 3 + src/lib/services/secureAuthService.ts | 179 ++ src/lib/services/taskService.ts | 26 +- src/lib/services/transactionService.ts | 92 +- src/lib/types.test.ts | 74 +- src/lib/types.ts | 244 +-- src/lib/utils/constant.ts | 1 + src/lib/utils/money.ts | 134 ++ src/locales/en/accounts.json | 4 +- src/locales/en/transactions.json | 11 +- src/locales/ja/accounts.json | 4 +- src/locales/ja/transactions.json | 11 +- src/locales/zh-CN/accounts.json | 4 +- src/locales/zh-CN/transactions.json | 11 +- src/locales/zh-TW/accounts.json | 4 +- src/locales/zh-TW/transactions.json | 11 +- 61 files changed, 13213 insertions(+), 683 deletions(-) create mode 100644 scripts/generate-proto.js create mode 100644 src/lib/crypto/browser-crypto.ts create mode 100644 src/lib/crypto/encoding.ts create mode 100644 src/lib/network/secure-client.ts create mode 100644 src/lib/proto/account/v1/account.ts create mode 100644 src/lib/proto/auth/v1/auth.ts create mode 100644 src/lib/proto/base/base.ts create mode 100644 src/lib/proto/config/v1/config.ts create mode 100644 src/lib/proto/dashboard/v1/dashboard.ts create mode 100644 src/lib/proto/data/v1/data.ts create mode 100644 src/lib/proto/google/protobuf/struct.ts create mode 100644 src/lib/proto/google/protobuf/timestamp.ts create mode 100644 src/lib/proto/health/v1/health.ts create mode 100644 src/lib/proto/hello/v1/hello.ts create mode 100644 src/lib/proto/task/v1/task.ts create mode 100644 src/lib/proto/transaction/v1/transaction.ts create mode 100644 src/lib/proto/user/v1/user.ts create mode 100644 src/lib/services/secureAuthService.ts create mode 100644 src/lib/utils/constant.ts create mode 100644 src/lib/utils/money.ts diff --git a/package-lock.json b/package-lock.json index 3cb88d9..c83905c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "gaap-web", "version": "0.1.0", "dependencies": { + "@bufbuild/protobuf": "^2.10.2", "@marsidev/react-turnstile": "^1.4.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -23,12 +24,15 @@ "@tanstack/react-query": "^5.90.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "decimal.js": "^10.6.0", "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", + "long": "^5.3.2", "lucide-react": "^0.554.0", "next": "^16.1.0", "next-themes": "^0.4.6", + "protobufjs": "^8.0.0", "qrcode.react": "^4.2.0", "react": "19.2.0", "react-dom": "19.2.0", @@ -44,6 +48,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", + "@types/decimal.js": "^0.0.32", "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", @@ -53,6 +58,7 @@ "jsdom": "^27.3.0", "nodemon": "^3.1.9", "tailwindcss": "^4", + "ts-proto": "^2.10.1", "tw-animate-css": "^1.4.0", "typescript": "^5", "vitest": "^4.0.16" @@ -171,7 +177,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -432,6 +437,12 @@ "node": ">=6.9.0" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", + "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -520,7 +531,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -564,7 +574,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1995,6 +2004,70 @@ "node": ">=12.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4422,7 +4495,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/query-core": "5.90.12" }, @@ -4674,6 +4746,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/decimal.js": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/decimal.js/-/decimal.js-0.0.32.tgz", + "integrity": "sha512-qiZoeFWRa6SaedYkSV8VrGV8xDGV3C6usFlUKOOl/fpvVdKVx+eHm+yHjJbGIuHgNsoe24wUddwLJGVBZFM5Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4704,9 +4783,7 @@ "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4717,7 +4794,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4728,7 +4804,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4785,7 +4860,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -5416,7 +5490,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5830,7 +5903,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -5924,6 +5996,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/chai": { "version": "6.2.1", "resolved": "https://registry.npmmirror.com/chai/-/chai-6.2.1.tgz", @@ -6318,9 +6403,8 @@ }, "node_modules/decimal.js": { "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/decimal.js/-/decimal.js-10.6.0.tgz", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/decimal.js-light": { @@ -6417,6 +6501,29 @@ "dev": true, "license": "MIT" }, + "node_modules/dprint-node": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/dprint-node/-/dprint-node-1.0.8.tgz", + "integrity": "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3" + } + }, + "node_modules/dprint-node/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6736,7 +6843,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6922,7 +7028,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7723,7 +7828,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -8351,7 +8455,6 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -8770,6 +8873,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -9484,6 +9593,30 @@ "react-is": "^16.13.1" } }, + "node_modules/protobufjs": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.0.tgz", + "integrity": "sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -9535,7 +9668,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9545,7 +9677,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9584,15 +9715,13 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9752,8 +9881,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -10540,8 +10668,7 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -10630,7 +10757,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10730,6 +10856,42 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-poet": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/ts-poet/-/ts-poet-6.12.0.tgz", + "integrity": "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dprint-node": "^1.0.8" + } + }, + "node_modules/ts-proto": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/ts-proto/-/ts-proto-2.10.1.tgz", + "integrity": "sha512-4sOE1hCs0uobJgdRCtcEwdbc8MAyKP+LJqUIKxZIiKac0rPBlVKsRGEGo2oQ1MnKA2Wwk0KuGP2POkiCwPtebw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.10.2", + "case-anything": "^2.1.13", + "ts-poet": "^6.12.0", + "ts-proto-descriptors": "2.1.0" + }, + "bin": { + "protoc-gen-ts_proto": "protoc-gen-ts_proto" + } + }, + "node_modules/ts-proto-descriptors": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-proto-descriptors/-/ts-proto-descriptors-2.1.0.tgz", + "integrity": "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bufbuild/protobuf": "^2.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10868,7 +11030,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10931,7 +11092,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": { @@ -11089,7 +11249,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11183,7 +11342,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11545,7 +11703,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 9e728a9..2e26c0b 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,11 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest" + "test": "vitest", + "proto": "node scripts/generate-proto.js" }, "dependencies": { + "@bufbuild/protobuf": "^2.10.2", "@marsidev/react-turnstile": "^1.4.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", @@ -27,12 +29,15 @@ "@tanstack/react-query": "^5.90.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "decimal.js": "^10.6.0", "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", "i18next-resources-to-backend": "^1.2.1", + "long": "^5.3.2", "lucide-react": "^0.554.0", "next": "^16.1.0", "next-themes": "^0.4.6", + "protobufjs": "^8.0.0", "qrcode.react": "^4.2.0", "react": "19.2.0", "react-dom": "19.2.0", @@ -48,6 +53,7 @@ "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", + "@types/decimal.js": "^0.0.32", "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", @@ -57,6 +63,7 @@ "jsdom": "^27.3.0", "nodemon": "^3.1.9", "tailwindcss": "^4", + "ts-proto": "^2.10.1", "tw-animate-css": "^1.4.0", "typescript": "^5", "vitest": "^4.0.16" diff --git a/scripts/generate-proto.js b/scripts/generate-proto.js new file mode 100644 index 0000000..16122b8 --- /dev/null +++ b/scripts/generate-proto.js @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.resolve(__dirname, '..'); +const protoRoot = path.resolve(projectRoot, '../gaap-api/manifest/protobuf'); +const outDir = path.resolve(projectRoot, 'src/lib/proto'); +const pluginPath = path.resolve(projectRoot, 'node_modules/.bin/protoc-gen-ts_proto' + (process.platform === 'win32' ? '.cmd' : '')); + +function getFiles(dir, extension, files = []) { + if (!fs.existsSync(dir)) return files; + const list = fs.readdirSync(dir); + for (const file of list) { + const name = path.join(dir, file); + if (fs.statSync(name).isDirectory()) { + getFiles(name, extension, files); + } else if (name.endsWith(extension)) { + // Use relative path from protoRoot for protoc, ensuring forward slashes + const relativePath = path.relative(protoRoot, name).split(path.sep).join('/'); + files.push(relativePath); + } + } + return files; +} + +if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); +} + +console.log(`Searching for .proto files in ${protoRoot}...`); +const protoFiles = getFiles(protoRoot, '.proto'); + +if (protoFiles.length === 0) { + console.error('No .proto files found in', protoRoot); + process.exit(1); +} + +const command = [ + 'protoc', + `--plugin=protoc-gen-ts_proto="${pluginPath}"`, + `--ts_proto_out="${outDir}"`, + `--proto_path="${protoRoot}"`, + ...protoFiles.map(f => `"${f}"`), + '--ts_proto_opt=esModuleInterop=true,forceLong=string' +].join(' '); + +console.log(`Generating ${protoFiles.length} protos...`); +try { + execSync(command, { cwd: protoRoot, stdio: 'inherit' }); + console.log('Successfully generated protos.'); +} catch { + console.error('Error generating protos.'); + process.exit(1); +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 253fbad..7aecb20 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -9,18 +9,19 @@ import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; -import apiRequest, { ApiError } from '@/lib/api'; import { sha256 } from '@/lib/utils'; +import { useRegister } from '@/lib/hooks'; + export default function RegisterPage() { const { t } = useTranslation(['auth', 'common', 'settings']); const router = useRouter(); + const registerMutation = useRegister(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [nickname, setNickname] = useState(''); const [turnstileToken, setTurnstileToken] = useState(''); - const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -32,35 +33,28 @@ export default function RegisterPage() { toast.error(t('auth:password_mismatch')); return; } - setLoading(true); try { const hashedPassword = await sha256(password); - await apiRequest('/api/auth/register', { - method: 'POST', - body: JSON.stringify({ - email, - password: hashedPassword, - nickname, - cf_turnstile_response: turnstileToken - }) + await registerMutation.mutateAsync({ + email, + password: hashedPassword, + nickname, + cfTurnstileResponse: turnstileToken }); - toast.success(t('auth:register_success')); - router.push('/login'); + // useRegister hook handles toast and tokens + router.push('/dashboard'); // Auto-login often redirects to dashboard, but let's see. Hook says "请登录" (Please login) but secureAuthService returns tokens. + // If secureAuthService auto-logs in, we should go to dashboard. + // Wait, useRegister hook toast says "注册成功,请登录" in original, but I updated it to just "注册成功". + // secureAuthService.register keeps tokens. So we can go to dashboard. } catch (err: unknown) { - if (err instanceof ApiError) { - toast.error(err.message); - } else if (err instanceof Error) { - toast.error(err.message); - } else { - toast.error(t('auth:unknown_error')); - } - } finally { - setLoading(false); + // Error handled by hook } }; + const loading = registerMutation.isPending; + return (
diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index d62786b..1550eb2 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useGlobal } from '@/context/GlobalContext'; -import { useLogin } from '@/lib/hooks'; +import { useLogin, UserLevelType } from '@/lib/hooks'; import { useTranslation } from 'react-i18next'; import { Turnstile } from '@marsidev/react-turnstile'; import { @@ -72,20 +72,20 @@ const LoginPage = () => { const data = await loginMutation.mutateAsync({ email, password: hashedPassword, - code: step === 2 ? code : undefined, - cf_turnstile_response: turnstileToken + code: step === 2 ? code : '', + cfTurnstileResponse: turnstileToken }); // Login success - if (!data || !data.user) { + if (!data || !data.auth || !data.auth.user) { throw new Error('Invalid response format'); } contextLogin({ - email: data.user.email, - nickname: data.user.nickname, - avatar: data.user.avatar, - plan: data.user.plan + email: data.auth.user.email, + nickname: data.auth.user.nickname, + avatar: data.auth.user.avatar, + plan: data.auth.user.plan }); toast.success(t('auth:login_success'), { duration: 4000 }); @@ -280,7 +280,7 @@ const LoginPage = () => { variant="outline" type="button" onClick={() => { - contextLogin({ email: 'github_user@example.com', nickname: 'GitHub User', plan: 'PRO' }); + contextLogin({ email: 'github_user@example.com', nickname: 'GitHub User', plan: UserLevelType.USER_LEVEL_TYPE_PRO }); router.push('/dashboard'); }} className="flex items-center justify-center gap-2 py-6 rounded-xl hover:bg-slate-50" @@ -292,7 +292,7 @@ const LoginPage = () => { variant="outline" type="button" onClick={() => { - contextLogin({ email: 'wechat_user@example.com', nickname: t('auth:default_wechat_username'), plan: 'FREE' }); + contextLogin({ email: 'wechat_user@example.com', nickname: t('auth:default_wechat_username'), plan: UserLevelType.USER_LEVEL_TYPE_FREE }); router.push('/dashboard'); }} className="flex items-center justify-center gap-2 py-6 rounded-xl hover:bg-slate-50" diff --git a/src/components/features/Accounts.tsx b/src/components/features/Accounts.tsx index e30209e..5bdd166 100644 --- a/src/components/features/Accounts.tsx +++ b/src/components/features/Accounts.tsx @@ -1,9 +1,10 @@ 'use client'; import React, { useState, useMemo } from 'react'; -import { useAllAccountsSuspense, Account } from '@/lib/hooks'; +import { useAllAccountsSuspense, Account, AccountType, Money } from '@/lib/hooks'; import { useTranslation } from 'react-i18next'; import { ACCOUNT_TYPES, EXCHANGE_RATES } from '@/lib/data'; +import { MoneyHelper } from '@/lib/utils/money'; import { Plus, Wallet, @@ -23,6 +24,7 @@ import EditAccountModal from './EditAccountModal'; const Accounts = () => { const { t } = useTranslation(['accounts', 'common']); const { accounts } = useAllAccountsSuspense(); + // We use string IDs for tabs, but map them to Enums for filtering const [activeTab, setActiveTab] = useState('ALL'); const [page, setPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); @@ -30,17 +32,39 @@ const Accounts = () => { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const itemsPerPage = 10; - const formatCurrency = (amount: number, currency = 'CNY') => { + const formatCurrency = (amount: number | Money, currency = 'CNY') => { + let val = 0; + if (typeof amount === 'number') { + val = amount; + } else { + // Assume Money proto or undefined + val = MoneyHelper.from(amount).toNumber(); + } + try { - return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount); + return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(val); } catch { - return `${currency} ${amount.toFixed(2)}`; + return `${currency} ${val.toFixed(2)}`; } }; + const tabToEnum: Record = useMemo(() => ({ + 'ASSET': AccountType.ACCOUNT_TYPE_ASSET, + 'LIABILITY': AccountType.ACCOUNT_TYPE_LIABILITY, + 'INCOME': AccountType.ACCOUNT_TYPE_INCOME, + 'EXPENSE': AccountType.ACCOUNT_TYPE_EXPENSE + }), []); + const topLevelAccounts = useMemo(() => { let filtered = accounts.filter(a => !a.parentId); - if (activeTab !== 'ALL') filtered = filtered.filter(a => a.type === activeTab); + + if (activeTab !== 'ALL') { + const targetType = tabToEnum[activeTab]; + if (targetType !== undefined) { + filtered = filtered.filter(a => a.type === targetType); + } + } + if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filtered.filter(parent => { @@ -49,14 +73,15 @@ const Accounts = () => { return children.some(child => child.name.toLowerCase().includes(query)); }); } - // Sort by created_at in descending order (newest first) + // Sort by createdAt in descending order (newest first) filtered.sort((a, b) => { - const dateA = a.created_at ? new Date(a.created_at).getTime() : 0; - const dateB = b.created_at ? new Date(b.created_at).getTime() : 0; + // Ensure date handling is safe + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return dateB - dateA; }); return filtered; - }, [accounts, activeTab, searchQuery]); + }, [accounts, activeTab, searchQuery, tabToEnum]); const totalPages = Math.ceil(topLevelAccounts.length / itemsPerPage); @@ -74,14 +99,19 @@ const Accounts = () => { ]; const AccountRow = ({ account, isChild = false, groupBalance, hasChildren = false }: { account: Account, isChild?: boolean, groupBalance?: number, hasChildren?: boolean }) => { - const TypeIcon = ACCOUNT_TYPES[account.type].icon; - const typeMeta = ACCOUNT_TYPES[account.type]; + const typeMeta = ACCOUNT_TYPES[account.type] || ACCOUNT_TYPES[AccountType.ACCOUNT_TYPE_ASSET]; + const TypeIcon = typeMeta.icon; const handleClick = () => { setEditAccount(account); setIsEditModalOpen(true); }; + // Safe access to currency + const currency = account.balance?.currencyCode || 'CNY'; + // Safe access to balance value + const balanceVal = account.balance; // Money object + if (account.isGroup) { const itemBg = hasChildren ? 'bg-[var(--bg-card)]' : 'bg-[var(--bg-main)]'; return ( @@ -124,17 +154,17 @@ const Accounts = () => {
{account.name} - {account.currency !== 'CNY' && ( - {account.currency} + {currency !== 'CNY' && ( + {currency} )}
- {!isChild &&
{t('common:' + account.type.toLowerCase())}
} + {!isChild &&
{t('common:' + typeMeta.label.toLowerCase())}
}
-
{formatCurrency(account.balance, account.currency)}
- {account.currency !== 'CNY' && ( -
≈ {formatCurrency(account.balance * (EXCHANGE_RATES[account.currency] || 1), 'CNY')}
+
{formatCurrency(MoneyHelper.from(balanceVal).toNumber(), currency)}
+ {currency !== 'CNY' && ( +
≈ {formatCurrency(MoneyHelper.from(balanceVal).toNumber() * (EXCHANGE_RATES[currency] || 1), 'CNY')}
)}
@@ -144,8 +174,10 @@ const Accounts = () => { const renderAccountCard = (parentAccount: Account) => { const children = accounts.filter(a => a.parentId === parentAccount.id); const groupBalance = children.reduce((sum, child) => { - const rate = EXCHANGE_RATES[child.currency] || 1; - return sum + (child.balance * rate); + const childIso = child.balance?.currencyCode || 'CNY'; + const rate = EXCHANGE_RATES[childIso] || 1; + const balVal = MoneyHelper.from(child.balance).toNumber(); + return sum + (balVal * rate); }, 0); return ( @@ -222,11 +254,13 @@ const Accounts = () => { setIsAddModalOpen(false)} /> - setIsEditModalOpen(false)} - account={editAccount} - /> + {editAccount && ( + { setIsEditModalOpen(false); setEditAccount(null); }} + account={editAccount} + /> + )} ); }; diff --git a/src/components/features/AddAccountModal.tsx b/src/components/features/AddAccountModal.tsx index 279af99..e778a2e 100644 --- a/src/components/features/AddAccountModal.tsx +++ b/src/components/features/AddAccountModal.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useGlobal } from '@/context/GlobalContext'; -import { useCreateAccount, useAllAccounts, AccountType } from '@/lib/hooks'; +import { useCreateAccount, useAllAccounts, AccountType, UserLevelType } from '@/lib/hooks'; import { ACCOUNT_TYPES } from '@/lib/data'; import { Dialog, @@ -41,7 +41,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { const { accounts } = useAllAccounts(); const createAccount = useCreateAccount(); - const [type, setType] = useState(AccountType.ASSET); + const [type, setType] = useState(AccountType.ACCOUNT_TYPE_ASSET); const [isGroup, setIsGroup] = useState(false); const [name, setName] = useState(''); const [date, setDate] = useState(new Date().toISOString().split('T')[0]); @@ -113,10 +113,11 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { const handleSubmit = async () => { if (!name) return; + const finalDate = date || new Date().toISOString().split('T')[0]; // FREE user ASSET limit check - if (type === AccountType.ASSET && user.plan === 'FREE') { - const currentAssetCount = accounts.filter(a => a.type === AccountType.ASSET && !a.parentId).length; + if (type === AccountType.ACCOUNT_TYPE_ASSET && user.plan === UserLevelType.USER_LEVEL_TYPE_FREE) { + const currentAssetCount = accounts.filter(a => a.type === AccountType.ACCOUNT_TYPE_ASSET && !a.parentId).length; if (currentAssetCount >= 5) { toast.error(t('accounts:free_asset_limit_reached')); return; @@ -124,29 +125,32 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { } try { - if (isGroup && (type === AccountType.ASSET || type === AccountType.LIABILITY)) { + if (isGroup && (type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY)) { // Create parent account first - const parent = await createAccount.mutateAsync({ + const res = await createAccount.mutateAsync({ name, type, isGroup: true, balance: 0, currency: 'CNY', - ...(date ? { date } : {}), + date: finalDate, ...(number ? { number } : {}), ...(remarks ? { remarks } : {}), }); - // Create children with parentId - for (const child of children) { - await createAccount.mutateAsync({ - parentId: parent.id, - name: child.name || `${name} ${child.currency}`, - type, - balance: parseFloat(child.balance) || 0, - currency: child.currency, - isGroup: false, - }); + if (res.account?.id) { + // Create children with parentId + for (const child of children) { + await createAccount.mutateAsync({ + parentId: res.account.id, + name: child.name || `${name} ${child.currency}`, + type, + balance: parseFloat(child.balance) || 0, + currency: child.currency, + isGroup: false, + date: finalDate + }); + } } } else { // Simple Account @@ -155,7 +159,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { type, balance: parseFloat(balance) || 0, currency, - ...(date ? { date } : {}), + date: finalDate, ...(number ? { number } : {}), ...(remarks ? { remarks } : {}), isGroup: false, @@ -171,7 +175,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => { } }; - const isPro = user.plan === 'PRO'; + const isPro = user.plan === UserLevelType.USER_LEVEL_TYPE_PRO; const isPending = createAccount.isPending; return ( @@ -188,12 +192,13 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
{ - setType(key as AccountType); - if (key === AccountType.INCOME || key === AccountType.EXPENSE) setIsGroup(false); + const typeVal = Number(key) as AccountType; + setType(typeVal); + if (typeVal === AccountType.ACCOUNT_TYPE_INCOME || typeVal === AccountType.ACCOUNT_TYPE_EXPENSE) setIsGroup(false); }} className={` cursor-pointer rounded-xl border-2 p-4 flex flex-col items-center gap-2 transition-all - ${type === key ? `border-[var(--primary)] bg-[var(--primary)]/5 ${value.color}` : 'border-transparent bg-[var(--bg-main)] text-[var(--text-muted)] hover:bg-[var(--bg-main)]/80'} + ${type === Number(key) ? `border-[var(--primary)] bg-[var(--primary)]/5 ${value.color}` : 'border-transparent bg-[var(--bg-main)] text-[var(--text-muted)] hover:bg-[var(--bg-main)]/80'} `} > @@ -203,7 +208,7 @@ const AddAccountModal = ({ isOpen, onClose }: AddAccountModalProps) => {
{/* Group Toggle - PRO only */} - {(type === AccountType.ASSET || type === AccountType.LIABILITY) && ( + {(type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY) && (
{
setBalance(e.target.value)} /> + {parseFloat(balance) !== 0 && (type === AccountType.ACCOUNT_TYPE_ASSET || type === AccountType.ACCOUNT_TYPE_LIABILITY) && ( +

{t('accounts:initial_balance_info')}

+ )}
)} diff --git a/src/components/features/BalanceTrendChart.tsx b/src/components/features/BalanceTrendChart.tsx index 05fe0a6..c9fe47f 100644 --- a/src/components/features/BalanceTrendChart.tsx +++ b/src/components/features/BalanceTrendChart.tsx @@ -20,9 +20,12 @@ import { DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import { useAllAccounts, useBalanceTrend, useProfile } from '@/lib/hooks'; +import { AccountType, useAllAccounts, useBalanceTrend, useProfile } from '@/lib/hooks'; import { EXCHANGE_RATES } from '@/lib/data'; import { ChevronDown, Loader2 } from 'lucide-react'; +import { MoneyHelper } from '@/lib/utils/money'; +import { DailyBalance } from '@/lib/types'; +import { DEFAULT_CURRENCY_CODE } from '@/lib/utils/constant'; const COLORS = [ 'var(--primary)', @@ -45,7 +48,7 @@ const BalanceTrendChart = () => { // Filter valid asset accounts for the dropdown const assetAccounts = useMemo(() => { - return accounts.filter(acc => acc.type === 'ASSET' && !acc.isGroup); + return accounts.filter(acc => acc.type === AccountType.ACCOUNT_TYPE_ASSET && !acc.isGroup); }, [accounts]); const toggleAccount = (id: string) => { @@ -78,18 +81,19 @@ const BalanceTrendChart = () => { const baseRate = EXCHANGE_RATES[mainCurrency] || 1; - return trendData.data.map(d => { + return trendData.data.map((d: DailyBalance) => { let allTotal = 0; const convertedBalances: Record = {}; Object.entries(d.balances).forEach(([id, balance]) => { const acc = accounts.find(a => a.id === id); - const accRate = acc ? (EXCHANGE_RATES[acc.currency] || 1) : 1; - const converted = balance * (accRate / baseRate); + const accRate = acc ? (EXCHANGE_RATES[acc.balance?.currencyCode || DEFAULT_CURRENCY_CODE] || 1) : 1; + const amount = MoneyHelper.from(balance).toNumber(); + const converted = amount * (accRate / baseRate); convertedBalances[id] = converted; - if (acc && acc.type === 'ASSET') { + if (acc && acc.type === AccountType.ACCOUNT_TYPE_ASSET) { allTotal += converted; } }); diff --git a/src/components/features/Dashboard.tsx b/src/components/features/Dashboard.tsx index 8763066..dc30c24 100644 --- a/src/components/features/Dashboard.tsx +++ b/src/components/features/Dashboard.tsx @@ -8,6 +8,8 @@ import { TransactionType } from '@/lib/types'; import { TrendingUp, TrendingDown } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; import BalanceTrendChart from './BalanceTrendChart'; +import { DEFAULT_CURRENCY_CODE } from '@/lib/utils/constant'; +import { MoneyHelper } from '@/lib/utils/money'; const Dashboard = () => { const { t } = useTranslation(['dashboard', 'common']); @@ -35,11 +37,11 @@ const Dashboard = () => { accounts.forEach(acc => { if (acc.isGroup) return; - const accRate = EXCHANGE_RATES[acc.currency] || 1; - const convertedBalance = acc.balance * (accRate / baseRate); + const accRate = EXCHANGE_RATES[acc.balance?.currencyCode || DEFAULT_CURRENCY_CODE] || 1; + const convertedBalance = MoneyHelper.from(acc.balance).toNumber() * (accRate / baseRate); - if (acc.type === AccountType.ASSET) assets += convertedBalance; - if (acc.type === AccountType.LIABILITY) liabilities += convertedBalance; + if (acc.type === AccountType.ACCOUNT_TYPE_ASSET) assets += convertedBalance; + if (acc.type === AccountType.ACCOUNT_TYPE_LIABILITY) liabilities += convertedBalance; }); return { assets, liabilities, netWorth: assets - liabilities }; }, [accounts, mainCurrency]); @@ -61,11 +63,11 @@ const Dashboard = () => { // Note: we're using string comparison which is safe for ISO format if (!tx.date.startsWith(currentMonth)) return; - const txRate = EXCHANGE_RATES[tx.currency] || 1; - const amount = tx.amount * (txRate / baseRate); + const txRate = EXCHANGE_RATES[tx.amount?.currencyCode || DEFAULT_CURRENCY_CODE] || 1; + const amount = MoneyHelper.from(tx.amount).toNumber() * (txRate / baseRate); - if (tx.type === TransactionType.INCOME) income += amount; - if (tx.type === TransactionType.EXPENSE) expense += amount; + if (tx.type === TransactionType.TRANSACTION_TYPE_INCOME) income += amount; + if (tx.type === TransactionType.TRANSACTION_TYPE_EXPENSE) expense += amount; }); return { income, expense }; diff --git a/src/components/features/EditAccountModal.tsx b/src/components/features/EditAccountModal.tsx index 728d66d..fdc58f5 100644 --- a/src/components/features/EditAccountModal.tsx +++ b/src/components/features/EditAccountModal.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { useGlobal } from '@/context/GlobalContext'; import { useUpdateAccount, useDeleteAccount, useCreateAccount, useAllAccounts, AccountType, Account } from '@/lib/hooks'; import { accountService } from '@/lib/services'; +import { ACCOUNT_TYPES } from '@/lib/data'; +import { MoneyHelper } from '@/lib/utils/money'; import { Dialog, DialogContent, @@ -50,8 +52,8 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { const [date, setDate] = useState(account.date || new Date().toISOString().split('T')[0]); const [number, setNumber] = useState(account.number || ''); const [remarks, setRemarks] = useState(account.remarks || ''); - const [balance, setBalance] = useState(account.balance?.toString() || '0'); - const [currency, setCurrency] = useState(account.currency || 'CNY'); + const [balance, setBalance] = useState(() => account.balance ? MoneyHelper.from(account.balance).toNumber().toString() : '0'); + const [currency, setCurrency] = useState(account.balance?.currencyCode || 'CNY'); // Group account state const isGroup = account.isGroup; @@ -61,8 +63,8 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { return accountChildren.map(c => ({ id: c.id, name: c.name, - currency: c.currency, - balance: c.balance.toString(), + currency: c.balance?.currencyCode || 'CNY', + balance: c.balance ? MoneyHelper.from(c.balance).toNumber().toString() : '0', isDefault: false, isNew: false })); @@ -111,6 +113,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { id: account.id, input: { name, + type: account.type, date, number, remarks, @@ -128,6 +131,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { balance: parseFloat(child.balance) || 0, currency: child.currency, isGroup: false, + date }); } else { await updateAccountMutation.mutateAsync({ @@ -154,7 +158,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { } return accounts.filter(a => - a.currency === curr && + a.balance?.currencyCode === curr && a.type === account.type && // Filter by same account type !accountsToDeleteIds.includes(a.id) && !a.isGroup @@ -206,8 +210,13 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { // Calculate if we have any blocked currencies (no targets available) const requiredCurrencies = Object.keys( - [account, ...(isGroup ? children : [])].reduce((acc, curr) => { - if (curr && curr.currency) acc[curr.currency] = true; + [ + isGroup ? null : account, + ...(isGroup ? children : []) + ].reduce((acc, curr) => { + // Fix: Account uses balance.currencyCode, ChildAccount uses currency + const currencyCode = (curr as Account)?.balance?.currencyCode || (curr as ChildAccount)?.currency; + if (curr && currencyCode) acc[currencyCode] = true; return acc; }, {} as Record) ); @@ -252,7 +261,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { type="number" value={balance} onChange={e => setBalance(e.target.value)} - disabled={account?.type === 'EXPENSE' || account?.type === 'INCOME'} + disabled={account?.type === AccountType.ACCOUNT_TYPE_EXPENSE || account?.type === AccountType.ACCOUNT_TYPE_INCOME} /> @@ -362,7 +371,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { {t('accounts:no_available_funding_accounts', { type: account?.type, - defaultValue: `No available ${account?.type} funding accounts, please create one before proceeding with deletion` + defaultValue: `No available ${account?.type ? t(`common:${ACCOUNT_TYPES[account.type]?.label.toLowerCase()}`) : ''} funding accounts, please create one before proceeding with deletion` })} @@ -376,7 +385,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { {targets.map(t => ( - {t.name} ({t.currency}) + {t.name} ({t.balance?.currencyCode || ''}) ))} diff --git a/src/components/features/Transactions.tsx b/src/components/features/Transactions.tsx index b6f9536..681415a 100644 --- a/src/components/features/Transactions.tsx +++ b/src/components/features/Transactions.tsx @@ -1,7 +1,9 @@ 'use client'; import React, { useState } from 'react'; -import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount, TransactionType, AccountType, Account, Transaction } from '@/lib/hooks'; +import { useAllTransactionsSuspense, useAllAccountsSuspense, useCreateTransaction, useUpdateTransaction, useDeleteTransaction, useCreateAccount } from '@/lib/hooks'; +import { TransactionType, AccountType, Account, Transaction, Money } from '@/lib/types'; +import { MoneyHelper } from '@/lib/utils/money'; import { useTranslation } from 'react-i18next'; import { Plus, ArrowRightLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -57,11 +59,18 @@ const Transactions = () => { const [newTx, setNewTx] = useState({ amount: '', note: '', from: '', to: '', date: getCurrentDateTime() }); - const formatCurrency = (amount: number, currency = 'CNY') => { + const formatCurrency = (amount: Money | number, currency = 'CNY') => { + let val = amount; + // Also handle Money encoded object + if (typeof amount === 'object' && amount !== null && ('units' in amount || 'nanos' in amount)) { + // Check if it has 'units' (string) or needs coercion + val = MoneyHelper.from(amount).toNumber(); + } + try { - return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(amount); + return new Intl.NumberFormat('zh-CN', { style: 'currency', currency }).format(val as number); } catch { - return `${currency} ${amount.toFixed(2)}`; + return `${currency} ${Number(val).toFixed(2)}`; } }; @@ -75,7 +84,13 @@ const Transactions = () => { } }; - const currentCurrency = accounts.find(a => a.id === newTx.from)?.currency || 'CNY'; + // Helper to safely get currency from an account + const getAccountCurrency = (account?: Account) => { + // If account.balance is undefined, default to CNY + return account?.balance?.currencyCode || 'CNY'; + } + + const currentCurrency = getAccountCurrency(accounts.find(a => a.id === newTx.from)); const getFullAccountName = (account: Account, allAccounts: Account[]) => { if (account.parentId) { @@ -147,41 +162,47 @@ const Transactions = () => { if (newTx.from === 'NEW_INCOME') { if (!newIncomeName) return; - const newAccount = await createAccount.mutateAsync({ + const res = await createAccount.mutateAsync({ name: newIncomeName, - type: AccountType.INCOME, + type: AccountType.ACCOUNT_TYPE_INCOME, currency: 'CNY', balance: 0, isGroup: false, + date: getCurrentDateTime(), }); - finalFromAccount = newAccount.id; + if (res.account) { + finalFromAccount = res.account.id; + } } if (newTx.to === 'NEW_EXPENSE') { if (!newExpenseName) return; const sourceAcc = accounts.find(a => a.id === finalFromAccount); - const newAccount = await createAccount.mutateAsync({ + const sourceCurrency = getAccountCurrency(sourceAcc); + const res = await createAccount.mutateAsync({ name: newExpenseName, - type: AccountType.EXPENSE, - currency: sourceAcc?.currency || 'CNY', + type: AccountType.ACCOUNT_TYPE_EXPENSE, + currency: sourceCurrency, balance: 0, isGroup: false, + date: getCurrentDateTime(), }); - console.log('Created account response:', newAccount); - finalToAccount = newAccount.id; - console.log('finalToAccount:', finalToAccount); + if (res.account) { + finalToAccount = res.account.id; + } } - const fromAccount = finalFromAccount === newTx.from ? accounts.find(a => a.id === newTx.from) : { type: AccountType.INCOME, currency: 'CNY' }; - const toAccount = finalToAccount === newTx.to ? accounts.find(a => a.id === newTx.to) : { type: AccountType.EXPENSE }; + const fromAccount = finalFromAccount === newTx.from ? accounts.find(a => a.id === newTx.from) : undefined; + const toAccount = finalToAccount === newTx.to ? accounts.find(a => a.id === newTx.to) : undefined; + const fromCurrency = getAccountCurrency(fromAccount); - let type = TransactionType.TRANSFER; - if (toAccount?.type === AccountType.EXPENSE) type = TransactionType.EXPENSE; - if (fromAccount?.type === AccountType.INCOME) type = TransactionType.INCOME; + let type = TransactionType.TRANSACTION_TYPE_TRANSFER; + if (toAccount?.type === AccountType.ACCOUNT_TYPE_EXPENSE) type = TransactionType.TRANSACTION_TYPE_EXPENSE; + if (fromAccount?.type === AccountType.ACCOUNT_TYPE_INCOME) type = TransactionType.TRANSACTION_TYPE_INCOME; let finalNote = newTx.note; if (!finalNote) { - const typeName = type === TransactionType.EXPENSE ? t('common:expense') : type === TransactionType.INCOME ? t('common:income') : t('transactions:transfer'); + const typeName = type === TransactionType.TRANSACTION_TYPE_EXPENSE ? t('common:expense') : type === TransactionType.TRANSACTION_TYPE_INCOME ? t('common:income') : t('transactions:transfer'); const targetAccountName = finalToAccount === newTx.to ? (accounts.find(a => a.id === newTx.to)?.name || '') : newExpenseName; @@ -198,7 +219,7 @@ const Transactions = () => { from: finalFromAccount, to: finalToAccount, amount: parseFloat(newTx.amount), - currency: fromAccount?.currency || 'CNY', + currency: fromCurrency, type, note: finalNote, date: dateToSend, @@ -228,8 +249,10 @@ const Transactions = () => { }; const handleEdit = (tx: Transaction) => { + // tx.amount is Money (proto). Construct a helper from it. + const amountVal = MoneyHelper.from(tx.amount).toNumber(); setNewTx({ - amount: tx.amount.toString(), + amount: amountVal.toString(), note: tx.note, from: tx.from, to: tx.to, @@ -334,12 +357,12 @@ const Transactions = () => { {t('transactions:asset_source')} - {renderAccountOptions(a => a.type === AccountType.ASSET)} + {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_ASSET)} {t('transactions:income_source')} {t('transactions:new_income_account')} - {renderAccountOptions(a => a.type === AccountType.INCOME)} + {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_INCOME)} @@ -356,15 +379,15 @@ const Transactions = () => { {t('transactions:expense_destination')} {t('transactions:new_expense_account')} - {renderAccountOptions(a => a.type === AccountType.EXPENSE)} + {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_EXPENSE)} {t('transactions:asset_deposit')} - {renderAccountOptions(a => a.type === AccountType.ASSET)} + {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_ASSET)} {t('transactions:liability_repayment')} - {renderAccountOptions(a => a.type === AccountType.LIABILITY)} + {renderAccountOptions(a => a.type === AccountType.ACCOUNT_TYPE_LIABILITY)} @@ -418,15 +441,26 @@ const Transactions = () => { {transactions.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).map(tx => { const fromAcc = accounts.find(a => a.id === tx.from); const toAcc = accounts.find(a => a.id === tx.to); + const isOpeningBalance = tx.type === TransactionType.TRANSACTION_TYPE_OPENING_BALANCE; + const txCurrency = tx.amount?.currencyCode || 'CNY'; return ( - handleEdit(tx)}> + !isOpeningBalance && handleEdit(tx)} + >
-
{tx.note || t('transactions:unnamed_transaction')}
+
+ {tx.note || t('transactions:unnamed_transaction')} + {isOpeningBalance && ( + {t('transactions:system_generated')} + )} +
{formatDateForDisplay(tx.date)}
-
{formatCurrency(tx.amount, tx.currency)}
+
{formatCurrency(tx.amount || 0, txCurrency)}
{fromAcc ? getFullAccountName(fromAcc, accounts) : t('transactions:unknown_account')} diff --git a/src/components/features/settings/CurrencySettings.tsx b/src/components/features/settings/CurrencySettings.tsx index 1d845ff..996205a 100644 --- a/src/components/features/settings/CurrencySettings.tsx +++ b/src/components/features/settings/CurrencySettings.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent } from '@/components/ui/card'; import { toast } from 'sonner'; +import { UserLevelType } from '@/lib/hooks'; export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; onUpgrade: () => void }) => { const { t } = useTranslation(['settings', 'common']); @@ -25,7 +26,7 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on // Fetch rates for Pro users useEffect(() => { - if (user.plan === 'PRO' && baseCurrency) { + if (user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && baseCurrency) { const fetchRates = async () => { setIsRefreshing(true); try { @@ -75,18 +76,18 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on

{t('settings:currency_management')}

- {user.plan === 'PRO' && ( + {user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && (
{isRefreshing ? t('settings:syncing') : t('settings:realtime_rates_active')}
)} - {user.plan === 'PRO' && exchangeRatesLastUpdated && ( + {user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && exchangeRatesLastUpdated && (
{t('settings:last_updated', { time: new Date(exchangeRatesLastUpdated).toLocaleString() })}
)} - {user.plan === 'FREE' && ( + {user.plan === UserLevelType.USER_LEVEL_TYPE_FREE && (
diff --git a/src/components/features/settings/UserProfile.tsx b/src/components/features/settings/UserProfile.tsx index ee60cd4..52a4a19 100644 --- a/src/components/features/settings/UserProfile.tsx +++ b/src/components/features/settings/UserProfile.tsx @@ -42,8 +42,8 @@ export const UserProfile = ({ onBack }: { onBack: () => void }) => { const handleGenerate2FA = async () => { try { const data = await generate2FA.mutateAsync(); - setQrUrl(data.url); - setSecret(data.secret); + setQrUrl(data.secret?.url || ''); + setSecret(data.secret?.secret || ''); setShow2FA(true); } catch (e: unknown) { console.error(e); diff --git a/src/context/GlobalContext.tsx b/src/context/GlobalContext.tsx index 92c3ccd..b14a108 100644 --- a/src/context/GlobalContext.tsx +++ b/src/context/GlobalContext.tsx @@ -2,7 +2,8 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { THEMES } from '@/lib/data'; -import apiRequest, { ApiError, API_BASE_PATH } from '@/lib/api'; +import { ApiError } from '@/lib/api'; +import { secureAuthService } from '@/lib/services/secureAuthService'; import { UserLevelType } from '@/lib/proto/base/base'; interface User { @@ -97,12 +98,30 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { return; } + // For ALE-encrypted endpoints, we also need a session key + const sessionKey = localStorage.getItem('sessionKey'); + if (!sessionKey) { + // Token exists but no session key - user needs to re-login + console.warn('Token exists but no session key, clearing tokens'); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + setIsLoading(false); + return; + } + try { - // Validate token by fetching profile - const data = await apiRequest<{ user: User }>(`${API_BASE_PATH}/user/get-profile`, { method: 'POST' }); + // Validate token by fetching profile using secure ALE-encrypted service + const data = await secureAuthService.getProfile(); if (data && data.user) { - setUser(data.user); + // Map the protobuf user response to our User type + setUser({ + email: data.user.email || '', + nickname: data.user.nickname || '', + avatar: data.user.avatar || null, + plan: data.user.plan ?? UserLevelType.UNRECOGNIZED, + twoFactorEnabled: data.user.twoFactorEnabled ?? false, + }); setIsLoggedIn(true); } else { throw new Error('Invalid user profile data'); diff --git a/src/lib/data.ts b/src/lib/data.ts index e1fc23c..c24f97c 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -98,11 +98,12 @@ export const THEMES = [ import { AccountType } from './types'; // Account type definitions -export const ACCOUNT_TYPES: Record }> = { - [AccountType.ACCOUNT_TYPE_ASSET]: { label: 'Assets', color: 'text-emerald-600', bg: 'bg-emerald-100', icon: Building2 }, - [AccountType.ACCOUNT_TYPE_LIABILITY]: { label: 'Liabilities', color: 'text-red-600', bg: 'bg-red-100', icon: CreditCard }, - [AccountType.ACCOUNT_TYPE_INCOME]: { label: 'Income', color: 'text-blue-600', bg: 'bg-blue-100', icon: Briefcase }, - [AccountType.ACCOUNT_TYPE_EXPENSE]: { label: 'Expenses', color: 'text-orange-600', bg: 'bg-orange-100', icon: Receipt }, +// Account type definitions +export const ACCOUNT_TYPES: Record }> = { + [AccountType.ACCOUNT_TYPE_ASSET]: { label: 'Assets', translationKey: 'asset', color: 'text-emerald-600', bg: 'bg-emerald-100', icon: Building2 }, + [AccountType.ACCOUNT_TYPE_LIABILITY]: { label: 'Liabilities', translationKey: 'liability', color: 'text-red-600', bg: 'bg-red-100', icon: CreditCard }, + [AccountType.ACCOUNT_TYPE_INCOME]: { label: 'Income', translationKey: 'income', color: 'text-blue-600', bg: 'bg-blue-100', icon: Briefcase }, + [AccountType.ACCOUNT_TYPE_EXPENSE]: { label: 'Expenses', translationKey: 'expense', color: 'text-orange-600', bg: 'bg-orange-100', icon: Receipt }, }; // Initial account data (includes parent-child structure) diff --git a/src/lib/network/secure-client.ts b/src/lib/network/secure-client.ts index bce28cc..5af283a 100644 --- a/src/lib/network/secure-client.ts +++ b/src/lib/network/secure-client.ts @@ -261,19 +261,20 @@ async function attemptTokenRefresh(): Promise { isRefreshing = true; - try { - const refreshToken = tokenStorage.getRefreshToken(); - if (!refreshToken) { - onRefreshComplete(false); - return false; - } + // Capture the token we are about to use + const initialRefreshToken = tokenStorage.getRefreshToken(); + if (!initialRefreshToken) { + onRefreshComplete(false); + return false; + } + try { // Import RefreshTokenReq/Res lazily to avoid circular deps const { RefreshTokenReq, RefreshTokenRes } = await import('../proto/auth/v1/auth'); const result = await secureRequest( '/auth/refresh-token', - { refreshToken }, + { refreshToken: initialRefreshToken }, RefreshTokenReq, RefreshTokenRes, 'bootstrap', @@ -295,6 +296,19 @@ async function attemptTokenRefresh(): Promise { onRefreshComplete(false); return false; } catch { + // If refresh failed (e.g. token revoked), check if another tab refreshed it. + // Give the other tab a moment to update localStorage. + await new Promise(resolve => setTimeout(resolve, 500)); + + const currentRefreshToken = tokenStorage.getRefreshToken(); + + // If the token in storage has changed since we started, it means another tab + // successfully refreshed it. We can consider this a success. + if (currentRefreshToken && currentRefreshToken !== initialRefreshToken) { + onRefreshComplete(true); + return true; + } + onRefreshComplete(false); return false; } finally { From 80a217002bc0461f2839de39b8899b53acbfe4c8 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:08:26 +0800 Subject: [PATCH 03/14] feat(settings): add confirmation flow for base currency updates - Implement two-step confirmation process for changing base currency - Add visual feedback during currency selection with confirmation states - Integrate secureAuthService to update user profile with new main currency - Add loading states and proper error handling for currency updates - Include auto-cancel functionality for confirmation after 3 seconds fix(accounts): replace hardcoded strings with internationalized messages - Add translation keys for account creation, update, and deletion messages - Support multiple languages (en, ja, zh-CN, zh-TW) for account operations - Update success and error toast notifications to use translated content - Remove hardcoded Chinese strings in favor of i18n implementation refactor(proto): update UserInput interface with mainCurrency field - Add optional mainCurrency property to UserInput interface - Update protobuf serialization/deserialization logic to handle mainCurrency - Modify default values and message processing for the new field - Ensure backward compatibility with existing user profiles refactor(auth): add updateProfile method to secureAuthService - Export UserInput type for consistent typing across services - Implement updateProfile endpoint using secureRequest pattern - Add proper request/response type definitions for profile updates - Maintain existing authentication flow while adding update capability chore(accounts): improve mutation hooks with proper translations - Add i18n support to account CRUD operation hooks - Update toast notifications to use localized messages - Fix type annotation for protoInput in update mutation - Maintain consistent error handling patterns across mutations refactor(websocket): change error logging level from error to warning - Update WebSocket error logging to use console.warn instead of console.error - Maintain same debugging information while reducing log severity - Keep connection status tracking consistent with new warning approach --- .../features/settings/CurrencySettings.tsx | 72 ++++++++++++++++--- src/lib/hooks/useAccounts.ts | 22 +++--- src/lib/hooks/useWebSocket.ts | 2 +- src/lib/proto/account/v1/account.ts | 4 +- src/lib/proto/user/v1/user.ts | 19 ++++- src/lib/services/secureAuthService.ts | 12 +++- src/locales/en/accounts.json | 8 ++- src/locales/en/settings.json | 2 + src/locales/ja/accounts.json | 8 ++- src/locales/ja/settings.json | 2 + src/locales/zh-CN/accounts.json | 8 ++- src/locales/zh-CN/settings.json | 2 + src/locales/zh-TW/accounts.json | 8 ++- src/locales/zh-TW/settings.json | 2 + 14 files changed, 143 insertions(+), 28 deletions(-) diff --git a/src/components/features/settings/CurrencySettings.tsx b/src/components/features/settings/CurrencySettings.tsx index 996205a..e8f27be 100644 --- a/src/components/features/settings/CurrencySettings.tsx +++ b/src/components/features/settings/CurrencySettings.tsx @@ -8,13 +8,17 @@ import { Card, CardContent } from '@/components/ui/card'; import { toast } from 'sonner'; import { UserLevelType } from '@/lib/hooks'; +import { secureAuthService } from '@/lib/services/secureAuthService'; + export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; onUpgrade: () => void }) => { const { t } = useTranslation(['settings', 'common']); const { user, currencies, addCurrency, deleteCurrency, exchangeRates, exchangeRatesLastUpdated, setExchangeRate, baseCurrency, setBaseCurrency } = useGlobal(); const [editingCurrency, setEditingCurrency] = useState(null); + const [confirmingBase, setConfirmingBase] = useState(null); const [editRate, setEditRate] = useState(''); const [newCurrency, setNewCurrency] = useState(''); const [isRefreshing, setIsRefreshing] = useState(false); + const [isUpdatingBase, setIsUpdatingBase] = useState(false); const handleAdd = (e: React.FormEvent) => { e.preventDefault(); @@ -24,8 +28,37 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on } }; + const handleSetBaseCurrency = async (currency: string) => { + if (confirmingBase === currency) { + // Confirm update + setIsUpdatingBase(true); + try { + await secureAuthService.updateProfile({ + nickname: user.nickname, + plan: user.plan, + avatar: user.avatar || undefined, + mainCurrency: currency + }); + setBaseCurrency(currency); + setConfirmingBase(null); + toast.success(t('settings:base_currency_updated')); + } catch (error) { + console.error('Failed to update base currency:', error); + toast.error(t('settings:update_failed')); + } finally { + setIsUpdatingBase(false); + } + } else { + // First click: Request confirmation + setConfirmingBase(currency); + // Auto-cancel confirmation after 3 seconds + setTimeout(() => setConfirmingBase(prev => prev === currency ? null : prev), 3000); + } + }; + // Fetch rates for Pro users useEffect(() => { + // ... (existing useEffect logic) ... if (user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && baseCurrency) { const fetchRates = async () => { setIsRefreshing(true); @@ -105,16 +138,35 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on

{t('settings:base_currency')}

- {currencies.map(curr => ( - - ))} + {currencies.map(curr => { + const isSelected = baseCurrency === curr; + const isConfirming = confirmingBase === curr; + + return ( + + ) + })}

{t('settings:base_currency_desc')} diff --git a/src/lib/hooks/useAccounts.ts b/src/lib/hooks/useAccounts.ts index 7763125..2363c87 100644 --- a/src/lib/hooks/useAccounts.ts +++ b/src/lib/hooks/useAccounts.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient, useSuspenseQuery } from '@tansta import { accountService } from '../services'; import { AccountInput, AccountQuery } from '../types'; import { toast } from 'sonner'; +import { useTranslation } from 'react-i18next'; // Query Keys export const accountKeys = { @@ -61,6 +62,7 @@ export interface AccountFormInput extends Omit { // Create account export function useCreateAccount(options?: { silent?: boolean }) { + const { t } = useTranslation('accounts'); const queryClient = useQueryClient(); return useMutation({ @@ -83,23 +85,24 @@ export function useCreateAccount(options?: { silent?: boolean }) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: accountKeys.lists() }); if (!options?.silent) { - toast.success('账户创建成功'); + toast.success(t('create_success')); } }, onError: (error: Error) => { - toast.error(error.message || '创建失败'); + toast.error(error.message || t('create_failed')); }, }); } // Update account export function useUpdateAccount() { + const { t } = useTranslation('accounts'); const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ id, input }: { id: string; input: Partial }) => { const { balance, currency, ...rest } = input; - const protoInput: any = { ...rest }; + const protoInput: Partial = { ...rest }; if (balance !== undefined && currency) { protoInput.balance = MoneyHelper.fromAmount(balance, currency).toProto(); } @@ -108,16 +111,17 @@ export function useUpdateAccount() { onSuccess: (_, { id }) => { queryClient.invalidateQueries({ queryKey: accountKeys.lists() }); queryClient.invalidateQueries({ queryKey: accountKeys.detail(id) }); - toast.success('账户更新成功'); + toast.success(t('update_success')); }, onError: (error: Error) => { - toast.error(error.message || '更新失败'); + toast.error(error.message || t('update_failed')); }, }); } // Delete account (creates migration task) export function useDeleteAccount() { + const { t } = useTranslation('accounts'); const queryClient = useQueryClient(); return useMutation({ @@ -126,13 +130,13 @@ export function useDeleteAccount() { onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: accountKeys.lists() }); if (result?.taskId) { - toast.info('迁移任务已创建,请在任务中心查看进度'); + toast.info(t('migration_task_started')); } else { - toast.success('账户删除成功'); + toast.success(t('delete_success')); } }, onError: (error: Error) => { - toast.error(error.message || '删除失败'); + toast.error(error.message || t('delete_failed')); }, }); } @@ -164,4 +168,4 @@ export function useAllAccountsSuspense() { accounts: data?.data ?? [], ...rest, }; -} \ No newline at end of file +} diff --git a/src/lib/hooks/useWebSocket.ts b/src/lib/hooks/useWebSocket.ts index f17ab3e..2797eea 100644 --- a/src/lib/hooks/useWebSocket.ts +++ b/src/lib/hooks/useWebSocket.ts @@ -83,7 +83,7 @@ export function useWebSocket(): UseWebSocketReturn { }; ws.onerror = (error) => { - console.error('[WebSocket] Error:', error); + console.warn('[WebSocket] Error:', error); setStatus('error'); }; diff --git a/src/lib/proto/account/v1/account.ts b/src/lib/proto/account/v1/account.ts index 99f4c96..59cff7c 100644 --- a/src/lib/proto/account/v1/account.ts +++ b/src/lib/proto/account/v1/account.ts @@ -27,8 +27,8 @@ export interface Account { /** Replaced float64 with Money */ balance: Money | undefined; defaultChildId?: - | string - | undefined; + | string + | undefined; /** ISO 8601 Date string */ date: string; number: string; diff --git a/src/lib/proto/user/v1/user.ts b/src/lib/proto/user/v1/user.ts index 7227b67..8472aec 100644 --- a/src/lib/proto/user/v1/user.ts +++ b/src/lib/proto/user/v1/user.ts @@ -31,6 +31,7 @@ export interface UserInput { nickname: string; avatar?: string | undefined; plan: UserLevelType; + mainCurrency?: string | undefined; } export interface GetUserProfileReq { @@ -258,7 +259,7 @@ export const User: MessageFns = { }; function createBaseUserInput(): UserInput { - return { nickname: "", avatar: undefined, plan: 0 }; + return { nickname: "", avatar: undefined, plan: 0, mainCurrency: undefined }; } export const UserInput: MessageFns = { @@ -272,6 +273,9 @@ export const UserInput: MessageFns = { if (message.plan !== 0) { writer.uint32(24).int32(message.plan); } + if (message.mainCurrency !== undefined) { + writer.uint32(34).string(message.mainCurrency); + } return writer; }, @@ -306,6 +310,14 @@ export const UserInput: MessageFns = { message.plan = reader.int32() as any; continue; } + case 4: { + if (tag !== 34) { + break; + } + + message.mainCurrency = reader.string(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -320,6 +332,7 @@ export const UserInput: MessageFns = { nickname: isSet(object.nickname) ? globalThis.String(object.nickname) : "", avatar: isSet(object.avatar) ? globalThis.String(object.avatar) : undefined, plan: isSet(object.plan) ? userLevelTypeFromJSON(object.plan) : 0, + mainCurrency: isSet(object.mainCurrency) ? globalThis.String(object.mainCurrency) : undefined, }; }, @@ -334,6 +347,9 @@ export const UserInput: MessageFns = { if (message.plan !== 0) { obj.plan = userLevelTypeToJSON(message.plan); } + if (message.mainCurrency !== undefined) { + obj.mainCurrency = message.mainCurrency; + } return obj; }, @@ -345,6 +361,7 @@ export const UserInput: MessageFns = { message.nickname = object.nickname ?? ""; message.avatar = object.avatar ?? undefined; message.plan = object.plan ?? 0; + message.mainCurrency = object.mainCurrency ?? undefined; return message; }, }; diff --git a/src/lib/services/secureAuthService.ts b/src/lib/services/secureAuthService.ts index 0699717..af344e9 100644 --- a/src/lib/services/secureAuthService.ts +++ b/src/lib/services/secureAuthService.ts @@ -27,10 +27,13 @@ import { import { GetUserProfileReq, GetUserProfileRes, + UpdateUserProfileReq, + UpdateUserProfileRes, + UserInput, } from '../proto/user/v1/user'; // Re-export types for convenience -export type { LoginRes, RegisterRes, RefreshTokenRes }; +export type { LoginRes, RegisterRes, RefreshTokenRes, UserInput }; /** * Secure Auth Service using ALE + Protobuf @@ -152,6 +155,13 @@ export const secureAuthService = { return secureRequest('/user/get-profile', {}, GetUserProfileReq, GetUserProfileRes, 'session'); }, + /** + * Update user profile + */ + updateProfile: async (input: UserInput): Promise => { + return secureRequest('/user/update-profile', { input }, UpdateUserProfileReq, UpdateUserProfileRes, 'session'); + }, + /** * Get stored tokens (for checking auth state) */ diff --git a/src/locales/en/accounts.json b/src/locales/en/accounts.json index e3f90d7..071cbaf 100644 --- a/src/locales/en/accounts.json +++ b/src/locales/en/accounts.json @@ -38,5 +38,11 @@ "free_group_disabled": "Parent-child accounts are available for Pro users only. Upgrade to unlock multi-currency account management.", "delete_no_transactions_confirm": "This account has no transactions. Are you sure you want to delete it? The balance will be cleared and this action cannot be undone.", "initial_balance_info": "An opening balance voucher will be generated automatically", - "equity": "Equity" + "equity": "Equity", + "create_success": "Account created successfully", + "create_failed": "Creation failed", + "update_success": "Account updated successfully", + "update_failed": "Update failed", + "delete_failed": "Deletion failed", + "migration_task_started": "Migration task created, please check progress in Task Center" } \ No newline at end of file diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 27e0e5d..11f37ad 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -38,6 +38,8 @@ "last_updated": "Last updated: {{time}}", "currency_placeholder": "Currency Code (e.g. GBP)", "sync_failed": "Failed to sync rates", + "base_currency_updated": "Base currency updated", + "update_failed": "Failed to update base currency", "rates_synced": "Exchange rates updated", "syncing": "Syncing...", "realtime_rates_active": "Real-time Rates Active", diff --git a/src/locales/ja/accounts.json b/src/locales/ja/accounts.json index c180e80..153826c 100644 --- a/src/locales/ja/accounts.json +++ b/src/locales/ja/accounts.json @@ -38,5 +38,11 @@ "free_group_disabled": "親子口座はProユーザー限定です。アップグレードして多通貨管理を解除。", "delete_no_transactions_confirm": "このアカウントには取引記録がありません。削除してもよろしいですか?残高はゼロになり、この操作は取り消せません。", "initial_balance_info": "期首残高仕訳が自動生成されます", - "equity": "資本" + "equity": "資本", + "create_success": "アカウントを作成しました", + "create_failed": "作成に失敗しました", + "update_success": "アカウントを更新しました", + "update_failed": "更新に失敗しました", + "delete_failed": "削除に失敗しました", + "migration_task_started": "移行タスクが作成されました。タスクセンターで進行状況を確認してください" } \ No newline at end of file diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json index 1ace342..b16572e 100644 --- a/src/locales/ja/settings.json +++ b/src/locales/ja/settings.json @@ -38,6 +38,8 @@ "last_updated": "最終更新: {{time}}", "currency_placeholder": "通貨コード (例: GBP)", "sync_failed": "レートの同期に失敗しました", + "base_currency_updated": "基本通貨が更新されました", + "update_failed": "基本通貨の更新に失敗しました", "rates_synced": "為替レートを更新しました", "syncing": "同期中...", "realtime_rates_active": "リアルタイムレート有効", diff --git a/src/locales/zh-CN/accounts.json b/src/locales/zh-CN/accounts.json index 5f64197..fb8a508 100644 --- a/src/locales/zh-CN/accounts.json +++ b/src/locales/zh-CN/accounts.json @@ -38,5 +38,11 @@ "free_group_disabled": "父子账户功能仅限 Pro 用户,升级后可使用多币种账户管理", "delete_no_transactions_confirm": "该账户没有交易记录,确定要删除吗?删除后余额将直接清零,此操作无法撤销。", "initial_balance_info": "系统将自动生成期初凭证", - "equity": "权益" + "equity": "权益", + "create_success": "账户创建成功", + "create_failed": "创建失败", + "update_success": "账户更新成功", + "update_failed": "更新失败", + "delete_failed": "删除失败", + "migration_task_started": "迁移任务已创建,请在任务中心查看进度" } \ No newline at end of file diff --git a/src/locales/zh-CN/settings.json b/src/locales/zh-CN/settings.json index 275ee84..38d191a 100644 --- a/src/locales/zh-CN/settings.json +++ b/src/locales/zh-CN/settings.json @@ -38,6 +38,8 @@ "last_updated": "最后更新: {{time}}", "currency_placeholder": "货币代码 (如 GBP)", "sync_failed": "同步汇率失败,请检查网络", + "base_currency_updated": "基准货币已更新", + "update_failed": "更新基准货币失败", "rates_synced": "汇率已更新", "syncing": "正在同步...", "realtime_rates_active": "实时汇率已激活", diff --git a/src/locales/zh-TW/accounts.json b/src/locales/zh-TW/accounts.json index b70a1cb..3db5ad2 100644 --- a/src/locales/zh-TW/accounts.json +++ b/src/locales/zh-TW/accounts.json @@ -38,5 +38,11 @@ "free_group_disabled": "父子帳戶功能僅限 Pro 用戶,升級後可使用多幣種帳戶管理", "delete_no_transactions_confirm": "該帳戶沒有交易記錄,確定要刪除嗎?刪除後餘額將直接清零,此操作無法撤銷。", "initial_balance_info": "系統將自動生成期初憑證", - "equity": "權益" + "equity": "權益", + "create_success": "帳戶建立成功", + "create_failed": "建立失敗", + "update_success": "帳戶更新成功", + "update_failed": "更新失敗", + "delete_failed": "刪除失敗", + "migration_task_started": "遷移任務已建立,請在任務中心查看進度" } \ No newline at end of file diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json index dc6eaac..85cc8ef 100644 --- a/src/locales/zh-TW/settings.json +++ b/src/locales/zh-TW/settings.json @@ -38,6 +38,8 @@ "last_updated": "最後更新: {{time}}", "currency_placeholder": "貨幣代碼 (如 GBP)", "sync_failed": "同步匯率失敗,請檢查網路", + "base_currency_updated": "基準貨幣已更新", + "update_failed": "更新基準貨幣失敗", "rates_synced": "匯率已更新", "syncing": "正在同步...", "realtime_rates_active": "即時匯率已啟用", From 893f87a205e043301a319d7d459a70cc70346909 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Sat, 24 Jan 2026 16:29:18 +0800 Subject: [PATCH 04/14] feat(auth): integrate global context for user registration flow - Import and utilize GlobalContext in the registration page to manage user state - Update global context with user data upon successful registration - Ensure seamless transition from registration to dashboard with proper authentication state fix(accounts): disable balance input for equity accounts - Extend the condition to disable balance input field for ACCOUNT_TYPE_EQUITY - Prevent users from modifying balance values for expense, income, and equity account types chore(deps): update package-lock.json peer dependency declarations - Add peer property to multiple dependencies in package-lock.json - Ensure proper peer dependency tracking for react, eslint, typescript and other packages - Maintain consistency in dependency management across the project --- package-lock.json | 30 ++++++++++++++++++-- src/app/register/page.tsx | 20 +++++++++---- src/components/features/EditAccountModal.tsx | 2 +- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index c83905c..94e1dbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -177,6 +177,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -531,6 +532,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -574,6 +576,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4495,6 +4498,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.90.12" }, @@ -4794,6 +4798,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4804,6 +4809,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4860,6 +4866,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -5490,6 +5497,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5903,6 +5911,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6843,6 +6852,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7028,6 +7038,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7828,6 +7839,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -8455,6 +8467,7 @@ "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -9668,6 +9681,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9677,6 +9691,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9715,13 +9730,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -9881,7 +9898,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -10668,7 +10686,8 @@ "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -10757,6 +10776,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11030,6 +11050,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11249,6 +11270,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -11342,6 +11364,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11703,6 +11726,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 7aecb20..853fc09 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; +import { useGlobal } from '@/context/GlobalContext'; import { sha256 } from '@/lib/utils'; @@ -16,6 +17,7 @@ import { useRegister } from '@/lib/hooks'; export default function RegisterPage() { const { t } = useTranslation(['auth', 'common', 'settings']); const router = useRouter(); + const { login: contextLogin } = useGlobal(); const registerMutation = useRegister(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -36,18 +38,24 @@ export default function RegisterPage() { try { const hashedPassword = await sha256(password); - await registerMutation.mutateAsync({ + const data = await registerMutation.mutateAsync({ email, password: hashedPassword, nickname, cfTurnstileResponse: turnstileToken }); - // useRegister hook handles toast and tokens - router.push('/dashboard'); // Auto-login often redirects to dashboard, but let's see. Hook says "请登录" (Please login) but secureAuthService returns tokens. - // If secureAuthService auto-logs in, we should go to dashboard. - // Wait, useRegister hook toast says "注册成功,请登录" in original, but I updated it to just "注册成功". - // secureAuthService.register keeps tokens. So we can go to dashboard. + // Registration with auto-login successful - update global context with user data + if (data && data.auth && data.auth.user) { + contextLogin({ + email: data.auth.user.email, + nickname: data.auth.user.nickname, + avatar: data.auth.user.avatar, + plan: data.auth.user.plan + }); + } + + router.push('/dashboard'); } catch (err: unknown) { // Error handled by hook } diff --git a/src/components/features/EditAccountModal.tsx b/src/components/features/EditAccountModal.tsx index 03e02ef..947d0ef 100644 --- a/src/components/features/EditAccountModal.tsx +++ b/src/components/features/EditAccountModal.tsx @@ -261,7 +261,7 @@ const EditAccountForm = ({ account, onClose }: EditAccountFormProps) => { type="number" value={balance} onChange={e => setBalance(e.target.value)} - disabled={account?.type === AccountType.ACCOUNT_TYPE_EXPENSE || account?.type === AccountType.ACCOUNT_TYPE_INCOME} + disabled={account?.type === AccountType.ACCOUNT_TYPE_EXPENSE || account?.type === AccountType.ACCOUNT_TYPE_INCOME || account?.type === AccountType.ACCOUNT_TYPE_EQUITY} />

From 77c6ee36a79208cd5ace7fae9bd4d812a0c2a20a Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:15:14 +0800 Subject: [PATCH 05/14] feat: enhance user feedback and error handling in registration and login flows --- src/app/register/page.tsx | 1 + src/components/auth/LoginPage.tsx | 2 - src/components/features/BalanceTrendChart.tsx | 32 ++++++++++--- src/components/layout/Sidebar.tsx | 12 ++++- src/lib/api.ts | 46 ++++++++++++++----- src/lib/hooks/useAuth.ts | 4 +- src/lib/network/secure-client.ts | 7 ++- src/lib/utils/money.ts | 10 ++-- src/locales/en/auth.json | 2 +- src/locales/ja/auth.json | 2 +- src/locales/zh-CN/auth.json | 2 +- src/locales/zh-TW/auth.json | 2 +- 12 files changed, 91 insertions(+), 31 deletions(-) diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 853fc09..e9642ce 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -58,6 +58,7 @@ export default function RegisterPage() { router.push('/dashboard'); } catch (err: unknown) { // Error handled by hook + toast.error(t('auth:register_failed'), { duration: 4000 }); } }; diff --git a/src/components/auth/LoginPage.tsx b/src/components/auth/LoginPage.tsx index 1550eb2..365769d 100644 --- a/src/components/auth/LoginPage.tsx +++ b/src/components/auth/LoginPage.tsx @@ -101,9 +101,7 @@ const LoginPage = () => { toast.error(t('auth:invalid_email_or_password'), { duration: 4000 }); return; } - // Error already handled by hook } else if (err instanceof Error) { - // Error already handled by hook } } }; diff --git a/src/components/features/BalanceTrendChart.tsx b/src/components/features/BalanceTrendChart.tsx index c9fe47f..84fd858 100644 --- a/src/components/features/BalanceTrendChart.tsx +++ b/src/components/features/BalanceTrendChart.tsx @@ -77,18 +77,28 @@ const BalanceTrendChart = () => { // Transform backend data for recharts const chartData = useMemo(() => { - if (!trendData?.data) return []; + if (!trendData?.data || !Array.isArray(trendData.data)) return []; const baseRate = EXCHANGE_RATES[mainCurrency] || 1; - return trendData.data.map((d: DailyBalance) => { + const td = trendData.data.map((d: DailyBalance) => { let allTotal = 0; const convertedBalances: Record = {}; + const balances = d.balances || {}; - Object.entries(d.balances).forEach(([id, balance]) => { + Object.entries(balances).forEach(([id, balance]) => { const acc = accounts.find(a => a.id === id); - const accRate = acc ? (EXCHANGE_RATES[acc.balance?.currencyCode || DEFAULT_CURRENCY_CODE] || 1) : 1; - const amount = MoneyHelper.from(balance).toNumber(); + // Prefer the per-day balance currency; fallback to account's currency or default + const currency = balance?.currencyCode || acc?.balance?.currencyCode || DEFAULT_CURRENCY_CODE; + const accRate = (EXCHANGE_RATES[currency] || 1); + + let amount = 0; + try { + amount = MoneyHelper.from(balance).toNumber(); + } catch (error) { + console.error(`Error parsing balance for account ${id}:`, error); + } + const converted = amount * (accRate / baseRate); convertedBalances[id] = converted; @@ -98,6 +108,15 @@ const BalanceTrendChart = () => { } }); + // Ensure selected accounts always have a numeric value (0 when missing) + const requiredIds = selectedAccountIds.includes('all') + ? accounts.filter(a => a.type === AccountType.ACCOUNT_TYPE_ASSET && !a.isGroup).map(a => a.id) + : selectedAccountIds; + + requiredIds.forEach(id => { + if (convertedBalances[id] === undefined) convertedBalances[id] = 0; + }); + return { date: d.date, displayDate: d.date.substring(5), // MM-DD @@ -105,7 +124,8 @@ const BalanceTrendChart = () => { all: allTotal }; }); - }, [trendData, accounts, mainCurrency]); + return td; + }, [trendData, accounts, mainCurrency, selectedAccountIds]); const currencySymbol = useMemo(() => { try { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index fb4db74..8ca1c07 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import React from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useGlobal } from '@/context/GlobalContext'; +import { UserLevelType } from '@/lib/hooks'; import { useTranslation } from 'react-i18next'; import { LayoutDashboard, @@ -20,6 +21,15 @@ const Sidebar = () => { const { user, setSettingsView } = useGlobal(); const { t } = useTranslation('common'); + console.info('User:', user); + + // Localized plan label + const planLabel = user?.plan === UserLevelType.USER_LEVEL_TYPE_PRO + ? t('settings:plans.pro.name') + : user?.plan === UserLevelType.USER_LEVEL_TYPE_FREE + ? t('settings:plans.free.name') + : ''; + const navItems = [ { href: '/dashboard', icon: LayoutDashboard, label: t('dashboard') }, { href: '/accounts', icon: Wallet, label: t('accounts') }, @@ -81,7 +91,7 @@ const Sidebar = () => {
{user.nickname}
-
{user.plan} {t('plan')}
+
{planLabel} {t('plan')}
diff --git a/src/lib/api.ts b/src/lib/api.ts index c025008..b93e252 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,12 +1,23 @@ // API base path - change this to modify the API prefix globally export const API_BASE_PATH = '/api/v1'; -export interface ApiResponse { +// Standard API Response wrapper (for legacy or non-proto endpoints) +export interface StandardResponse { code: number; - message: string; + message?: string; data: T; } +// Type guard for StandardResponse +function isStandardResponse(res: unknown): res is StandardResponse { + return ( + typeof res === 'object' && + res !== null && + 'code' in res && + typeof (res as StandardResponse).code === 'number' + ); +} + export class ApiError extends Error { code: number; data: unknown; @@ -110,11 +121,18 @@ async function apiRequest(url: string, options: RequestInit = {}): const parseResponse = async (response: Response): Promise => { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { - const resData: ApiResponse = await response.json(); - if (resData.code !== 0) { - throw new ApiError(resData.message || 'Unknown error', resData.code, resData.data); + const resData: unknown = await response.json(); + + // Check if response is wrapped in { code, data } + if (isStandardResponse(resData)) { + if (resData.code !== 0) { + throw new ApiError(resData.message || 'Unknown error', resData.code, resData.data); + } + return resData.data; } - return resData.data; + + // Assume direct Protobuf response (no code wrapper) + return resData as R; } else { if (!response.ok) { throw new ApiError(`HTTP Error: ${response.status} ${response.statusText}`, response.status); @@ -130,10 +148,11 @@ async function apiRequest(url: string, options: RequestInit = {}): // Check Content-Type const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { - const resData: ApiResponse = await response.json(); + const resData: unknown = await response.json(); // Handle 401 Unauthorized - attempt token refresh - if (resData.code === 401) { + // Check if it's a standard response with code 401 + if (isStandardResponse(resData) && resData.code === 401) { // Skip refresh for auth endpoints if (url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh-token')) { throw new ApiError(resData.message || 'Unauthorized', resData.code, resData.data); @@ -180,11 +199,14 @@ async function apiRequest(url: string, options: RequestInit = {}): } } - // If code is not 0, it's a business error - if (resData.code !== 0) { - throw new ApiError(resData.message || 'Unknown error', resData.code, resData.data); + // If code is defined, check error, otherwise return raw data + if (isStandardResponse(resData)) { + if (resData.code !== 0) { + throw new ApiError(resData.message || 'Unknown error', resData.code, resData.data); + } + return resData.data; } - return resData.data; + return resData as T; } else { // Non-JSON response (e.g. 404 html page) if (!response.ok) { diff --git a/src/lib/hooks/useAuth.ts b/src/lib/hooks/useAuth.ts index c0e1f4d..1e17a8d 100644 --- a/src/lib/hooks/useAuth.ts +++ b/src/lib/hooks/useAuth.ts @@ -28,7 +28,7 @@ export function useLogin() { queryClient.clear(); }, onError: (error: Error) => { - toast.error(error.message || '登录失败'); + // Handle specific error messages for better UX }, }); } @@ -49,7 +49,7 @@ export function useRegister() { toast.success('注册成功'); }, - onError: (error: Error) => toast.error(error.message || '注册失败'), + // onError: (error: Error) => toast.error(error.message || '注册失败'), }); } diff --git a/src/lib/network/secure-client.ts b/src/lib/network/secure-client.ts index 5af283a..16478e8 100644 --- a/src/lib/network/secure-client.ts +++ b/src/lib/network/secure-client.ts @@ -384,9 +384,14 @@ export async function logout( ResType: MessageFns ): Promise { try { - await secureRequest('/auth/logout', {}, ReqType, ResType, 'session'); + // Use the bootstrap key for auth endpoints (server ALE middleware for /auth/* is bootstrap) + await secureRequest('/auth/logout', {}, ReqType, ResType, 'bootstrap'); } finally { tokenStorage.clear(); resetNetworkState(); + // Redirect to login page after logout + if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) { + window.location.href = '/login'; + } } } diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index f691fb5..05aa1ea 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -37,12 +37,16 @@ export class MoneyHelper { return new MoneyHelper(new Decimal(0), ''); } - const unitsDec = new Decimal(input.units); - const nanosDec = new Decimal(input.nanos).div(NANOS_MOD); + // Allow missing units/nanos (server may omit them when value is zero) + const units = input.units ?? 0; + const nanos = input.nanos ?? 0; + + const unitsDec = new Decimal(units); + const nanosDec = new Decimal(nanos).div(NANOS_MOD); const total = unitsDec.plus(nanosDec); - return new MoneyHelper(total, input.currencyCode); + return new MoneyHelper(total, input.currencyCode || ''); } /** diff --git a/src/locales/en/auth.json b/src/locales/en/auth.json index f5748e5..a6eb94e 100644 --- a/src/locales/en/auth.json +++ b/src/locales/en/auth.json @@ -26,7 +26,7 @@ "register_title": "Create Account", "register_subtitle": "Create your GAAP account", "register_success": "Registration successful", - "register_failed": "Registration failed", + "register_failed": "Registration failed, please try again later...", "verifying": "Verifying...", "registering": "Registering...", "logging_in": "Logging in...", diff --git a/src/locales/ja/auth.json b/src/locales/ja/auth.json index 874a0d7..4337786 100644 --- a/src/locales/ja/auth.json +++ b/src/locales/ja/auth.json @@ -26,7 +26,7 @@ "register_title": "アカウント登録", "register_subtitle": "GAAPアカウントを作成", "register_success": "登録成功", - "register_failed": "登録失敗", + "register_failed": "登録失敗,後でもう一度お試しください...", "verifying": "認証中...", "registering": "登録中...", "logging_in": "ログイン中...", diff --git a/src/locales/zh-CN/auth.json b/src/locales/zh-CN/auth.json index c2ad629..9860bdd 100644 --- a/src/locales/zh-CN/auth.json +++ b/src/locales/zh-CN/auth.json @@ -26,7 +26,7 @@ "register_title": "注册账户", "register_subtitle": "创建您的 GAAP 账户", "register_success": "注册成功", - "register_failed": "注册失败", + "register_failed": "注册失败,请稍候再试...", "verifying": "验证中...", "registering": "注册中...", "logging_in": "登录中...", diff --git a/src/locales/zh-TW/auth.json b/src/locales/zh-TW/auth.json index 26fe495..9a04440 100644 --- a/src/locales/zh-TW/auth.json +++ b/src/locales/zh-TW/auth.json @@ -26,7 +26,7 @@ "register_title": "註冊帳戶", "register_subtitle": "建立您的 GAAP 帳戶", "register_success": "註冊成功", - "register_failed": "註冊失敗", + "register_failed": "註冊失敗,請稍候再試...", "verifying": "驗證中...", "registering": "註冊中...", "logging_in": "登入中...", From 10c6eafdea3a51b416fce97be8c3cc313e0c0db0 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:09:36 +0800 Subject: [PATCH 06/14] fix: lint problems. --- src/app/register/page.tsx | 2 +- src/context/__tests__/GlobalContext.test.tsx | 82 +++++++++----------- src/lib/hooks/useAuth.ts | 4 +- 3 files changed, 40 insertions(+), 48 deletions(-) diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index e9642ce..c599f24 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -56,7 +56,7 @@ export default function RegisterPage() { } router.push('/dashboard'); - } catch (err: unknown) { + } catch { // Error handled by hook toast.error(t('auth:register_failed'), { duration: 4000 }); } diff --git a/src/context/__tests__/GlobalContext.test.tsx b/src/context/__tests__/GlobalContext.test.tsx index f603278..c3bb785 100644 --- a/src/context/__tests__/GlobalContext.test.tsx +++ b/src/context/__tests__/GlobalContext.test.tsx @@ -1,29 +1,34 @@ import React from 'react'; import { render, screen, waitFor, act } from '@testing-library/react'; import { GlobalProvider, useGlobal } from '../GlobalContext'; -import { API_BASE_PATH } from '../../lib/api'; +import { ApiError } from '../../lib/api'; import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { GetUserProfileRes } from '../../lib/proto/user/v1/user'; -// Test component to consume context -const TestComponent = () => { - const { isLoggedIn, user, isLoading } = useGlobal(); - if (isLoading) return
Loading...
; - if (isLoggedIn) return
Logged in as {user.nickname}
; - return
Not logged in
; -}; - -// Helper to create mock response -const createMockResponse = (data: unknown, code = 0, status = 200) => ({ - ok: status >= 200 && status < 300, - status, - headers: { - get: (name: string) => name === 'content-type' ? 'application/json' : null, +// Mock secureAuthService +vi.mock('../../lib/services/secureAuthService', () => ({ + secureAuthService: { + getProfile: vi.fn(), }, - json: async () => ({ code, message: code === 0 ? 'success' : 'error', data }), -}); +})); + +// Import after mock +import { secureAuthService } from '../../lib/services/secureAuthService'; -// Mock fetch -global.fetch = vi.fn(); +// Test component to access context +const TestComponent = () => { + const { user, isLoading } = useGlobal(); + + if (isLoading) { + return
Loading...
; + } + + if (!user) { + return
Not logged in
; + } + + return
Logged in as {user.nickname}
; +}; // Mock localStorage const localStorageMock = (() => { @@ -63,11 +68,12 @@ describe('GlobalContext Authentication', () => { it('should authenticate automatically if valid token exists', async () => { localStorageMock.setItem('token', 'valid-token'); + localStorageMock.setItem('sessionKey', 'valid-session-key'); // Mock successful profile fetch - vi.mocked(global.fetch).mockResolvedValueOnce( - createMockResponse({ user: { email: 'test@example.com', nickname: 'TestUser', plan: 'FREE' } }) as Response - ); + vi.mocked(secureAuthService.getProfile).mockResolvedValue({ + user: { email: 'test@example.com', nickname: 'TestUser', plan: 1 } // FREE plan + } as GetUserProfileRes); await act(async () => { render( @@ -80,27 +86,17 @@ describe('GlobalContext Authentication', () => { await waitFor(() => { expect(screen.getByText('Logged in as TestUser')).toBeInTheDocument(); }); - - expect(global.fetch).toHaveBeenCalledWith(`${API_BASE_PATH}/user/profile`, expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer valid-token' }) - })); }); it('should refresh token if initial fetch returns 401', async () => { localStorageMock.setItem('token', 'expired-token'); localStorageMock.setItem('refreshToken', 'valid-refresh-token'); + localStorageMock.setItem('sessionKey', 'valid-session-key'); - // 1. Profile fetch -> 401 (business error code) - vi.mocked(global.fetch) - .mockResolvedValueOnce(createMockResponse(null, 401) as Response) - // 2. Refresh fetch -> 200 - .mockResolvedValueOnce( - createMockResponse({ accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }) as Response - ) - // 3. Retry profile fetch -> 200 - .mockResolvedValueOnce( - createMockResponse({ user: { email: 'test@example.com', nickname: 'RefreshedUser', plan: 'PRO' } }) as Response - ); + // Mock profile fetch to succeed (assuming refresh happened internally) + vi.mocked(secureAuthService.getProfile).mockResolvedValue({ + user: { email: 'test@example.com', nickname: 'RefreshedUser', plan: 2 } // PRO plan + } as GetUserProfileRes); await act(async () => { render( @@ -114,20 +110,16 @@ describe('GlobalContext Authentication', () => { expect(screen.getByText('Logged in as RefreshedUser')).toBeInTheDocument(); }); - // Check localStorage updates - expect(localStorageMock.setItem).toHaveBeenCalledWith('token', 'new-access-token'); - expect(localStorageMock.setItem).toHaveBeenCalledWith('refreshToken', 'new-refresh-token'); + // Note: Token refresh happens internally in secureRequest, so we can't easily test localStorage updates }); it('should log out if refresh fails', async () => { localStorageMock.setItem('token', 'expired-token'); localStorageMock.setItem('refreshToken', 'bad-refresh-token'); + localStorageMock.setItem('sessionKey', 'valid-session-key'); - // 1. Profile fetch -> 401 - vi.mocked(global.fetch) - .mockResolvedValueOnce(createMockResponse(null, 401) as Response) - // 2. Refresh fetch -> 401 - .mockResolvedValueOnce(createMockResponse(null, 401) as Response); + // Mock profile fetch to fail with 401 + vi.mocked(secureAuthService.getProfile).mockRejectedValue(new ApiError('Unauthorized', 401)); await act(async () => { render( diff --git a/src/lib/hooks/useAuth.ts b/src/lib/hooks/useAuth.ts index 1e17a8d..2bebe52 100644 --- a/src/lib/hooks/useAuth.ts +++ b/src/lib/hooks/useAuth.ts @@ -22,12 +22,12 @@ export function useLogin() { return useMutation({ mutationFn: (input: LoginInput) => secureAuthService.login(input), - onSuccess: (data) => { + onSuccess: () => { // Tokens are already handled by secureAuthService // Clear ALL cached queries to prevent stale errors from being replayed queryClient.clear(); }, - onError: (error: Error) => { + onError: () => { // Handle specific error messages for better UX }, }); From d4fd39e3ac8506736798da19c2ceb3fbe647d65e Mon Sep 17 00:00:00 2001 From: MelodicGin <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:11:06 +0800 Subject: [PATCH 07/14] Update src/context/GlobalContext.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/context/GlobalContext.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/context/GlobalContext.tsx b/src/context/GlobalContext.tsx index b14a108..aeed2ea 100644 --- a/src/context/GlobalContext.tsx +++ b/src/context/GlobalContext.tsx @@ -103,8 +103,9 @@ export const GlobalProvider = ({ children }: { children: React.ReactNode }) => { if (!sessionKey) { // Token exists but no session key - user needs to re-login console.warn('Token exists but no session key, clearing tokens'); - localStorage.removeItem('token'); - localStorage.removeItem('refreshToken'); + // Clear all auth-related tokens/session state + secureAuthService.clearTokens(); + localStorage.removeItem('sessionKey'); setIsLoading(false); return; } From 67e4d668ed39f3d0ccb34d34888e845431e7ac64 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:15:33 +0800 Subject: [PATCH 08/14] feat(tests): update TestComponent to reflect user login state --- src/context/__tests__/GlobalContext.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/context/__tests__/GlobalContext.test.tsx b/src/context/__tests__/GlobalContext.test.tsx index c3bb785..358cf63 100644 --- a/src/context/__tests__/GlobalContext.test.tsx +++ b/src/context/__tests__/GlobalContext.test.tsx @@ -17,17 +17,17 @@ import { secureAuthService } from '../../lib/services/secureAuthService'; // Test component to access context const TestComponent = () => { - const { user, isLoading } = useGlobal(); + const { user, isLoggedIn, isLoading } = useGlobal(); if (isLoading) { return
Loading...
; } - if (!user) { - return
Not logged in
; + if (isLoggedIn) { + return
Logged in as {user.nickname}
; } - return
Logged in as {user.nickname}
; + return
Not logged in
; }; // Mock localStorage From 6e75d1088302a5b335f0b8ef5d97974f8f1ff6bb Mon Sep 17 00:00:00 2001 From: MelodicGin <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:32:54 +0800 Subject: [PATCH 09/14] Update src/components/layout/Sidebar.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/layout/Sidebar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 8ca1c07..8ec9e8f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -21,8 +21,6 @@ const Sidebar = () => { const { user, setSettingsView } = useGlobal(); const { t } = useTranslation('common'); - console.info('User:', user); - // Localized plan label const planLabel = user?.plan === UserLevelType.USER_LEVEL_TYPE_PRO ? t('settings:plans.pro.name') From b71af022ac360f29eafde363157fbcc49a574ec6 Mon Sep 17 00:00:00 2001 From: MelodicGin <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:33:19 +0800 Subject: [PATCH 10/14] Update src/components/features/settings/CurrencySettings.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/features/settings/CurrencySettings.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/features/settings/CurrencySettings.tsx b/src/components/features/settings/CurrencySettings.tsx index e8f27be..cefc86f 100644 --- a/src/components/features/settings/CurrencySettings.tsx +++ b/src/components/features/settings/CurrencySettings.tsx @@ -58,7 +58,8 @@ export const CurrencySettings = ({ onBack, onUpgrade }: { onBack: () => void; on // Fetch rates for Pro users useEffect(() => { - // ... (existing useEffect logic) ... + // When base currency, user plan, or the number of tracked currencies changes, refresh + // exchange rates for Pro users so calculations stay in sync with the latest API data. if (user.plan === UserLevelType.USER_LEVEL_TYPE_PRO && baseCurrency) { const fetchRates = async () => { setIsRefreshing(true); From abff9e2b77da726b6acfe14def1f3cb87795f41b Mon Sep 17 00:00:00 2001 From: MelodicGin <4485145+gin-melodic@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:33:43 +0800 Subject: [PATCH 11/14] Update src/components/features/Transactions.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/features/Transactions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/features/Transactions.tsx b/src/components/features/Transactions.tsx index 681415a..2ef86a2 100644 --- a/src/components/features/Transactions.tsx +++ b/src/components/features/Transactions.tsx @@ -88,7 +88,7 @@ const Transactions = () => { const getAccountCurrency = (account?: Account) => { // If account.balance is undefined, default to CNY return account?.balance?.currencyCode || 'CNY'; - } + }; const currentCurrency = getAccountCurrency(accounts.find(a => a.id === newTx.from)); From 936451019040dfff2d7e5dd80e6a7e620df37bae Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 19:55:23 +0800 Subject: [PATCH 12/14] fix: Add SSR guards to tokenStorage setters and clear method (#6) * Initial plan * fix: add window guard to tokenStorage setters and clear method Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/network/secure-client.ts | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/lib/network/secure-client.ts b/src/lib/network/secure-client.ts index 16478e8..33ab819 100644 --- a/src/lib/network/secure-client.ts +++ b/src/lib/network/secure-client.ts @@ -48,21 +48,33 @@ const SESSION_KEY = 'sessionKey'; export const tokenStorage: TokenStorage = { getToken: () => (typeof window !== 'undefined' ? localStorage.getItem(TOKEN_KEY) : null), - setToken: (token) => localStorage.setItem(TOKEN_KEY, token), + setToken: (token) => { + if (typeof window !== 'undefined') { + localStorage.setItem(TOKEN_KEY, token); + } + }, getRefreshToken: () => (typeof window !== 'undefined' ? localStorage.getItem(REFRESH_TOKEN_KEY) : null), - setRefreshToken: (token) => localStorage.setItem(REFRESH_TOKEN_KEY, token), + setRefreshToken: (token) => { + if (typeof window !== 'undefined') { + localStorage.setItem(REFRESH_TOKEN_KEY, token); + } + }, getSessionKey: () => (typeof window !== 'undefined' ? localStorage.getItem(SESSION_KEY) : null), setSessionKey: (key) => { - if (key) { - localStorage.setItem(SESSION_KEY, key); - } else { - localStorage.removeItem(SESSION_KEY); + if (typeof window !== 'undefined') { + if (key) { + localStorage.setItem(SESSION_KEY, key); + } else { + localStorage.removeItem(SESSION_KEY); + } } }, clear: () => { - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(REFRESH_TOKEN_KEY); - localStorage.removeItem(SESSION_KEY); + if (typeof window !== 'undefined') { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.removeItem(SESSION_KEY); + } }, }; From b3039dd0f6420d295afc25435aaa2b66ff63f4cc Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:45:28 +0800 Subject: [PATCH 13/14] Fix nanos overflow in MoneyHelper.toProto() (#7) * Initial plan * fix: normalize nanos overflow in MoneyHelper.toProto() Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: use while loop for more robust nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: simplify nanos normalization logic Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: use Decimal comparison methods for cleaner code Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: use exact equality checks for nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: optimize nanos normalization and clean up tests Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * refactor: use defensive >= and <= checks for nanos normalization Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> * test: add assertions to verify nanos always within valid range Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gin-melodic <4485145+gin-melodic@users.noreply.github.com> --- src/lib/utils/money.test.ts | 178 ++++++++++++++++++++++++++++++++++++ src/lib/utils/money.ts | 18 +++- 2 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/lib/utils/money.test.ts diff --git a/src/lib/utils/money.test.ts b/src/lib/utils/money.test.ts new file mode 100644 index 0000000..11461ba --- /dev/null +++ b/src/lib/utils/money.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect } from 'vitest'; +import { MoneyHelper } from './money'; + +describe('MoneyHelper', () => { + describe('toProto()', () => { + it('should handle normal values', () => { + const money = MoneyHelper.fromAmount(123.45, 'USD'); + const proto = money.toProto(); + + expect(proto.currencyCode).toBe('USD'); + expect(proto.units).toBe('123'); + expect(proto.nanos).toBe(450_000_000); + }); + + it('should normalize when nanos rounds to 1_000_000_000', () => { + // Create a value that when rounded will produce exactly 1_000_000_000 nanos + // 0.9999999995 should round to 1.0 when multiplied by 1_000_000_000 + const money = MoneyHelper.fromAmount(0.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('1'); + expect(proto.nanos).toBe(0); + }); + + it('should normalize when nanos rounds to -1_000_000_000', () => { + // Create a value that when rounded will produce exactly -1_000_000_000 nanos + const money = MoneyHelper.fromAmount(-0.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-1'); + expect(proto.nanos).toBe(0); + }); + + it('should handle positive values close to rounding boundary', () => { + const money = MoneyHelper.fromAmount(5.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('6'); + expect(proto.nanos).toBe(0); + }); + + it('should handle negative values close to rounding boundary', () => { + const money = MoneyHelper.fromAmount(-5.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-6'); + expect(proto.nanos).toBe(0); + }); + + it('should handle zero', () => { + const money = MoneyHelper.fromAmount(0, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('0'); + expect(proto.nanos).toBe(0); + }); + + it('should handle large positive values', () => { + const money = MoneyHelper.fromAmount(999999.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('1000000'); + expect(proto.nanos).toBe(0); + }); + + it('should handle large negative values', () => { + const money = MoneyHelper.fromAmount(-999999.9999999995, 'USD'); + const proto = money.toProto(); + + expect(proto.units).toBe('-1000000'); + expect(proto.nanos).toBe(0); + }); + + it('should maintain consistency: from -> toProto -> from should preserve value', () => { + const originalAmount = 123.456789012; + const money1 = MoneyHelper.fromAmount(originalAmount, 'USD'); + const proto = money1.toProto(); + + // Verify proto has valid nanos (this is the bug fix test) + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + + const money2 = MoneyHelper.from(proto); + + // The round-trip should preserve the value within reasonable precision + expect(money2.toNumber()).toBeCloseTo(originalAmount, 9); + }); + + it('should handle arithmetic results that may round to boundary', () => { + // Test a division that might produce a value close to rounding boundary + const money = MoneyHelper.fromAmount(10, 'USD'); + const result = money.div(3).mul(3); // 10/3*3 may have precision issues + const proto = result.toProto(); + + // Should not throw and should have valid nanos + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + }); + + it('should always produce valid nanos for any amount', () => { + // Test various random amounts to ensure nanos is always valid + const testAmounts = [ + 0.9999999995, -0.9999999995, + 1.9999999995, -1.9999999995, + 999.9999999995, -999.9999999995, + 0.123456789, -0.123456789, + 12345.6789, -12345.6789, + ]; + + for (const amount of testAmounts) { + const proto = MoneyHelper.fromAmount(amount, 'USD').toProto(); + expect(proto.nanos).toBeGreaterThanOrEqual(-999_999_999); + expect(proto.nanos).toBeLessThanOrEqual(999_999_999); + } + }); + }); + + describe('from()', () => { + it('should construct from valid proto', () => { + const proto = { + currencyCode: 'USD', + units: '123', + nanos: 450_000_000, + }; + const money = MoneyHelper.from(proto); + + expect(money.toNumber()).toBeCloseTo(123.45, 9); + expect(money.currency).toBe('USD'); + }); + + it('should handle null/undefined', () => { + const money1 = MoneyHelper.from(null); + expect(money1.toNumber()).toBe(0); + + const money2 = MoneyHelper.from(undefined); + expect(money2.toNumber()).toBe(0); + }); + }); + + describe('arithmetic operations', () => { + it('should add correctly', () => { + const m1 = MoneyHelper.fromAmount(100.50, 'USD'); + const m2 = MoneyHelper.fromAmount(50.25, 'USD'); + const result = m1.add(m2); + + expect(result.toNumber()).toBeCloseTo(150.75, 9); + }); + + it('should subtract correctly', () => { + const m1 = MoneyHelper.fromAmount(100.50, 'USD'); + const m2 = MoneyHelper.fromAmount(50.25, 'USD'); + const result = m1.sub(m2); + + expect(result.toNumber()).toBeCloseTo(50.25, 9); + }); + + it('should multiply correctly', () => { + const money = MoneyHelper.fromAmount(100, 'USD'); + const result = money.mul(1.5); + + expect(result.toNumber()).toBeCloseTo(150, 9); + }); + + it('should divide correctly', () => { + const money = MoneyHelper.fromAmount(100, 'USD'); + const result = money.div(4); + + expect(result.toNumber()).toBeCloseTo(25, 9); + }); + + it('should throw on currency mismatch', () => { + const m1 = MoneyHelper.fromAmount(100, 'USD'); + const m2 = MoneyHelper.fromAmount(100, 'EUR'); + + expect(() => m1.add(m2)).toThrow('Currency mismatch'); + }); + }); +}); diff --git a/src/lib/utils/money.ts b/src/lib/utils/money.ts index 05aa1ea..955e064 100644 --- a/src/lib/utils/money.ts +++ b/src/lib/utils/money.ts @@ -61,13 +61,23 @@ export class MoneyHelper { * 对应 Go 的 ToEntityValues */ toProto(): MoneyProto { - const unitsDec = this.amount.trunc(); - const nanosDec = this.amount.minus(unitsDec).times(NANOS_MOD).round(); + let units = this.amount.trunc(); + let nanos = this.amount.minus(units).times(NANOS_MOD).round(); + + // Normalize: carry overflow/underflow nanos into units + // Use >= and <= for defensive programming, though rounding should only produce exactly ±NANOS_MOD + if (nanos.gte(NANOS_MOD)) { + units = units.plus(1); + nanos = nanos.minus(NANOS_MOD); + } else if (nanos.lte(-NANOS_MOD)) { + units = units.minus(1); + nanos = nanos.plus(NANOS_MOD); + } return { currencyCode: this.currency, - units: unitsDec.toString(), - nanos: nanosDec.toNumber(), + units: units.toString(), + nanos: nanos.toNumber(), }; } From 2034b2d798373722873f484436a16015e795e108 Mon Sep 17 00:00:00 2001 From: gin-melodic <4485145+gin-melodic@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:48:30 +0800 Subject: [PATCH 14/14] fix: fix import sentence to avoid unnecessary runtime imports and potential circular deps. --- src/lib/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types.ts b/src/lib/types.ts index 382bbc5..671fbbf 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -89,7 +89,7 @@ export { } from './proto/dashboard/v1/dashboard'; // Aliases -import { LoginReq, RegisterReq } from './proto/auth/v1/auth'; +import type { LoginReq, RegisterReq } from './proto/auth/v1/auth'; export type LoginInput = LoginReq; export type RegisterInput = RegisterReq;