Commit 060b0c04 authored by ThinhNC's avatar ThinhNC

feat(fe): implement authentication and user profile management

parent 8621dab9
...@@ -21,11 +21,17 @@ ...@@ -21,11 +21,17 @@
"build": "vite build" "build": "vite build"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.19.0",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.84.0",
"zmp-sdk": "latest", "zmp-sdk": "latest",
"zmp-ui": "latest" "zmp-ui": "latest",
"zod": "^4.4.3",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1", "@types/react": "^18.3.1",
......
...@@ -8,6 +8,15 @@ importers: ...@@ -8,6 +8,15 @@ importers:
.: .:
dependencies: dependencies:
'@hookform/resolvers':
specifier: ^5.7.1
version: 5.7.1(react-hook-form@7.84.0(react@18.3.1))(zod@4.4.3)
'@tanstack/react-query':
specifier: ^5.101.4
version: 5.101.4(react@18.3.1)
axios:
specifier: ^1.19.0
version: 1.19.0
jotai: jotai:
specifier: ^2.12.1 specifier: ^2.12.1
version: 2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1) version: 2.20.2(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1)
...@@ -17,12 +26,21 @@ importers: ...@@ -17,12 +26,21 @@ importers:
react-dom: react-dom:
specifier: ^18.3.1 specifier: ^18.3.1
version: 18.3.1(react@18.3.1) version: 18.3.1(react@18.3.1)
react-hook-form:
specifier: ^7.84.0
version: 7.84.0(react@18.3.1)
zmp-sdk: zmp-sdk:
specifier: latest specifier: latest
version: 2.52.2(@babel/core@7.29.7) version: 2.52.2(@babel/core@7.29.7)
zmp-ui: zmp-ui:
specifier: latest specifier: latest
version: 1.11.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 1.11.14(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
zod:
specifier: ^4.4.3
version: 4.4.3
zustand:
specifier: ^5.0.14
version: 5.0.14(@types/react@18.3.31)(react@18.3.1)
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^18.3.1 specifier: ^18.3.1
...@@ -303,6 +321,84 @@ packages: ...@@ -303,6 +321,84 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@hookform/resolvers@5.7.1':
resolution: {integrity: sha512-8wS/P4UDr5sQDe4nFaV51TVyfDPrWgNIXweqG0Bs9Z5LSuzKLb+RQNPvkN2oHM5SRrJyWrVH/F+LOUcFjUyvwQ==}
peerDependencies:
'@sinclair/typebox': '>=0.25.24'
'@standard-schema/spec': ^1.0.0
'@typeschema/main': '>=0.13.7'
'@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0
ajv: ^8.12.0
ajv-errors: ^3.0.0
ajv-formats: ^2.1.1
arktype: ^2.0.0
ata-validator: ^1.2.0
class-transformer: '>=0.4.0'
class-validator: '>=0.12.0'
computed-types: ^1.0.0
effect: ^3.10.3
fluentvalidation-ts: ^3.0.0
fp-ts: ^2.7.0
io-ts: ^2.0.0
joi: ^17.0.0
nope-validator: '>=0.12.0'
react-hook-form: ^7.55.0
superstruct: '>=0.12.0'
typanion: ^3.3.2
valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc'
vest: '>=3.0.0'
yup: ^1.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
'@sinclair/typebox':
optional: true
'@standard-schema/spec':
optional: true
'@typeschema/main':
optional: true
'@vinejs/vine':
optional: true
ajv:
optional: true
ajv-errors:
optional: true
ajv-formats:
optional: true
arktype:
optional: true
ata-validator:
optional: true
class-transformer:
optional: true
class-validator:
optional: true
computed-types:
optional: true
effect:
optional: true
fluentvalidation-ts:
optional: true
fp-ts:
optional: true
io-ts:
optional: true
joi:
optional: true
nope-validator:
optional: true
superstruct:
optional: true
typanion:
optional: true
valibot:
optional: true
vest:
optional: true
yup:
optional: true
zod:
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
...@@ -623,12 +719,23 @@ packages: ...@@ -623,12 +719,23 @@ packages:
resolution: {integrity: sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==} resolution: {integrity: sha512-z95ECmE3i9pbWoXQrD/7PgkBAzJYR+iXtPuTkpBjDKs86O3mT+PXOT3BAn79w2wkn7/i3vOGD2xVr1uiMl26dA==}
engines: {node: '>=6'} engines: {node: '>=6'}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@swc/helpers@0.4.14': '@swc/helpers@0.4.14':
resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==}
'@swc/helpers@0.4.37': '@swc/helpers@0.4.37':
resolution: {integrity: sha512-O4U8DmGtYvuWDrqmkAqhmA+sV8D3eJzvKSUgg5L5eaCCPdywZBLc97UgJT/fQaCkQ5onJzJWNojgErJk1bThaw==} resolution: {integrity: sha512-O4U8DmGtYvuWDrqmkAqhmA+sV8D3eJzvKSUgg5L5eaCCPdywZBLc97UgJT/fQaCkQ5onJzJWNojgErJk1bThaw==}
'@tanstack/query-core@5.101.4':
resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==}
'@tanstack/react-query@5.101.4':
resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==}
peerDependencies:
react: ^18 || ^19
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
...@@ -669,6 +776,10 @@ packages: ...@@ -669,6 +776,10 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ansi-regex@5.0.1: ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
...@@ -691,6 +802,9 @@ packages: ...@@ -691,6 +802,9 @@ packages:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'} engines: {node: '>=8'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
at-least-node@1.0.0: at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'} engines: {node: '>= 4.0.0'}
...@@ -706,6 +820,9 @@ packages: ...@@ -706,6 +820,9 @@ packages:
resolution: {integrity: sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==} resolution: {integrity: sha512-eM9d/swFopRt5gdJ7jrpCwgvEMIayITpojhkkSMRsFHYuH5bkSQ4p/9qTEHtmNudUZh22Tehu7I6CxAW0IXTKA==}
hasBin: true hasBin: true
axios@1.19.0:
resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
...@@ -730,6 +847,10 @@ packages: ...@@ -730,6 +847,10 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true hasBin: true
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
camelcase-css@2.0.1: camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
...@@ -770,6 +891,10 @@ packages: ...@@ -770,6 +891,10 @@ packages:
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@4.1.1: commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
...@@ -837,6 +962,10 @@ packages: ...@@ -837,6 +962,10 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dependency-graph@0.9.0: dependency-graph@0.9.0:
resolution: {integrity: sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==} resolution: {integrity: sha512-9YLIBURXj4DJMFALxXw9K3Y3rwb5Fk0X5/8ipCzaN84+gKxoHK43tVKRNakCQbiEx07E8Uwhuq21BpUagFhZ8w==}
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
...@@ -861,16 +990,32 @@ packages: ...@@ -861,16 +990,32 @@ packages:
dom-helpers@5.2.1: dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
electron-to-chromium@1.5.402: electron-to-chromium@1.5.402:
resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==}
emoji-regex@8.0.0: emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0: es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.21.5: esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'} engines: {node: '>=12'}
...@@ -911,6 +1056,19 @@ packages: ...@@ -911,6 +1056,19 @@ packages:
resolution: {integrity: sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==} resolution: {integrity: sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==}
deprecated: flatten is deprecated in favor of utility frameworks such as lodash. deprecated: flatten is deprecated in favor of utility frameworks such as lodash.
follow-redirects@1.16.0:
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.6:
resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
engines: {node: '>= 6'}
fraction.js@5.3.4: fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
...@@ -940,6 +1098,14 @@ packages: ...@@ -940,6 +1098,14 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*} engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stdin@8.0.0: get-stdin@8.0.0:
resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==} resolution: {integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==}
engines: {node: '>=10'} engines: {node: '>=10'}
...@@ -960,6 +1126,10 @@ packages: ...@@ -960,6 +1126,10 @@ packages:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'} engines: {node: '>=10'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
...@@ -967,10 +1137,22 @@ packages: ...@@ -967,10 +1137,22 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.4: hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
...@@ -1079,6 +1261,10 @@ packages: ...@@ -1079,6 +1261,10 @@ packages:
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
engines: {node: '>=6'} engines: {node: '>=6'}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
merge2@1.4.1: merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
...@@ -1087,6 +1273,14 @@ packages: ...@@ -1087,6 +1273,14 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
minimatch@3.1.5: minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
...@@ -1395,6 +1589,10 @@ packages: ...@@ -1395,6 +1589,10 @@ packages:
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
qrcode@1.5.4: qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
...@@ -1412,6 +1610,12 @@ packages: ...@@ -1412,6 +1610,12 @@ packages:
peerDependencies: peerDependencies:
react: ^18.3.1 react: ^18.3.1
react-hook-form@7.84.0:
resolution: {integrity: sha512-+hWvQP6GLco56mDwrbU4XnHix8t1z90ltZsDIrREl+jnQFQxYLX8oAzqe/Xn8nHpmoXTY5M6oEXrAhbP1qevNQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-is@16.13.1: react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
...@@ -1686,6 +1890,27 @@ packages: ...@@ -1686,6 +1890,27 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 vite: ^4.2.0 || ^5.0.0
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zustand@5.0.14:
resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==}
engines: {node: '>=12.20.0'}
peerDependencies:
'@types/react': '>=18.0.0'
immer: '>=9.0.6'
react: '>=18.0.0'
use-sync-external-store: '>=1.2.0'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
use-sync-external-store:
optional: true
snapshots: snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
...@@ -1889,6 +2114,13 @@ snapshots: ...@@ -1889,6 +2114,13 @@ snapshots:
'@esbuild/win32-x64@0.21.5': '@esbuild/win32-x64@0.21.5':
optional: true optional: true
'@hookform/resolvers@5.7.1(react-hook-form@7.84.0(react@18.3.1))(zod@4.4.3)':
dependencies:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.84.0(react@18.3.1)
optionalDependencies:
zod: 4.4.3
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
...@@ -2137,6 +2369,8 @@ snapshots: ...@@ -2137,6 +2369,8 @@ snapshots:
'@sentry/types': 6.19.7 '@sentry/types': 6.19.7
tslib: 1.14.1 tslib: 1.14.1
'@standard-schema/utils@0.3.0': {}
'@swc/helpers@0.4.14': '@swc/helpers@0.4.14':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
...@@ -2146,6 +2380,13 @@ snapshots: ...@@ -2146,6 +2380,13 @@ snapshots:
'@swc/legacy-helpers': '@swc/helpers@0.4.14' '@swc/legacy-helpers': '@swc/helpers@0.4.14'
tslib: 2.8.1 tslib: 2.8.1
'@tanstack/query-core@5.101.4': {}
'@tanstack/react-query@5.101.4(react@18.3.1)':
dependencies:
'@tanstack/query-core': 5.101.4
react: 18.3.1
'@types/babel__core@7.20.5': '@types/babel__core@7.20.5':
dependencies: dependencies:
'@babel/parser': 7.29.8 '@babel/parser': 7.29.8
...@@ -2199,6 +2440,12 @@ snapshots: ...@@ -2199,6 +2440,12 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
ansi-styles@4.3.0: ansi-styles@4.3.0:
...@@ -2216,6 +2463,8 @@ snapshots: ...@@ -2216,6 +2463,8 @@ snapshots:
array-union@2.1.0: {} array-union@2.1.0: {}
asynckit@0.4.0: {}
at-least-node@1.0.0: {} at-least-node@1.0.0: {}
autoprefixer@10.5.4(postcss@8.5.26): autoprefixer@10.5.4(postcss@8.5.26):
...@@ -2237,6 +2486,16 @@ snapshots: ...@@ -2237,6 +2486,16 @@ snapshots:
postcss: 7.0.39 postcss: 7.0.39
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
axios@1.19.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
https-proxy-agent: 5.0.1
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
- supports-color
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
baseline-browser-mapping@2.11.12: {} baseline-browser-mapping@2.11.12: {}
...@@ -2260,6 +2519,11 @@ snapshots: ...@@ -2260,6 +2519,11 @@ snapshots:
node-releases: 2.0.53 node-releases: 2.0.53
update-browserslist-db: 1.2.3(browserslist@4.28.7) update-browserslist-db: 1.2.3(browserslist@4.28.7)
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
camelcase@5.3.1: {} camelcase@5.3.1: {}
...@@ -2307,6 +2571,10 @@ snapshots: ...@@ -2307,6 +2571,10 @@ snapshots:
color-name@1.1.4: {} color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@4.1.1: {} commander@4.1.1: {}
commander@6.2.1: {} commander@6.2.1: {}
...@@ -2352,6 +2620,8 @@ snapshots: ...@@ -2352,6 +2620,8 @@ snapshots:
decamelize@1.2.0: {} decamelize@1.2.0: {}
delayed-stream@1.0.0: {}
dependency-graph@0.9.0: {} dependency-graph@0.9.0: {}
detect-libc@2.1.2: detect-libc@2.1.2:
...@@ -2372,12 +2642,31 @@ snapshots: ...@@ -2372,12 +2642,31 @@ snapshots:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
csstype: 3.2.3 csstype: 3.2.3
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
electron-to-chromium@1.5.402: {} electron-to-chromium@1.5.402: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.4
esbuild@0.21.5: esbuild@0.21.5:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5 '@esbuild/aix-ppc64': 0.21.5
...@@ -2435,6 +2724,16 @@ snapshots: ...@@ -2435,6 +2724,16 @@ snapshots:
flatten@1.0.3: {} flatten@1.0.3: {}
follow-redirects@1.16.0: {}
form-data@4.0.6:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.4
mime-types: 2.1.35
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
fs-extra@9.1.0: fs-extra@9.1.0:
...@@ -2457,6 +2756,24 @@ snapshots: ...@@ -2457,6 +2756,24 @@ snapshots:
get-caller-file@2.0.5: {} get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
get-stdin@8.0.0: {} get-stdin@8.0.0: {}
glob-parent@5.1.2: glob-parent@5.1.2:
...@@ -2485,14 +2802,29 @@ snapshots: ...@@ -2485,14 +2802,29 @@ snapshots:
merge2: 1.4.1 merge2: 1.4.1
slash: 3.0.0 slash: 3.0.0
gopd@1.2.0: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.4: hasown@2.0.4:
dependencies: dependencies:
function-bind: 1.1.2 function-bind: 1.1.2
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ignore@5.3.2: {} ignore@5.3.2: {}
immutable@5.1.9: {} immutable@5.1.9: {}
...@@ -2570,6 +2902,8 @@ snapshots: ...@@ -2570,6 +2902,8 @@ snapshots:
pify: 4.0.1 pify: 4.0.1
semver: 5.7.2 semver: 5.7.2
math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}
micromatch@4.0.8: micromatch@4.0.8:
...@@ -2577,6 +2911,12 @@ snapshots: ...@@ -2577,6 +2911,12 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.2 picomatch: 2.3.2
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
minimatch@3.1.5: minimatch@3.1.5:
dependencies: dependencies:
brace-expansion: 1.1.18 brace-expansion: 1.1.18
...@@ -2915,6 +3255,8 @@ snapshots: ...@@ -2915,6 +3255,8 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
react-is: 16.13.1 react-is: 16.13.1
proxy-from-env@2.1.0: {}
qrcode@1.5.4: qrcode@1.5.4:
dependencies: dependencies:
dijkstrajs: 1.0.3 dijkstrajs: 1.0.3
...@@ -2931,6 +3273,10 @@ snapshots: ...@@ -2931,6 +3273,10 @@ snapshots:
react: 18.3.1 react: 18.3.1
scheduler: 0.23.2 scheduler: 0.23.2
react-hook-form@7.84.0(react@18.3.1):
dependencies:
react: 18.3.1
react-is@16.13.1: {} react-is@16.13.1: {}
react-is@18.3.1: {} react-is@18.3.1: {}
...@@ -3243,3 +3589,10 @@ snapshots: ...@@ -3243,3 +3589,10 @@ snapshots:
zmp-vite-plugin@1.1.6(vite@5.4.21(sass@1.102.0)): zmp-vite-plugin@1.1.6(vite@5.4.21(sass@1.102.0)):
dependencies: dependencies:
vite: 5.4.21(sass@1.102.0) vite: 5.4.21(sass@1.102.0)
zod@4.4.3: {}
zustand@5.0.14(@types/react@18.3.31)(react@18.3.1):
optionalDependencies:
'@types/react': 18.3.31
react: 18.3.1
import React, { useEffect } from "react";
import { getSystemInfo } from "zmp-sdk"; import { getSystemInfo } from "zmp-sdk";
import { import {
AnimationRoutes, AnimationRoutes,
...@@ -7,21 +8,69 @@ import { ...@@ -7,21 +8,69 @@ import {
ZMPRouter, ZMPRouter,
} from "zmp-ui"; } from "zmp-ui";
import { AppProps } from "zmp-ui/app"; import { AppProps } from "zmp-ui/app";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { AuthGuard } from "@/components/shared/AuthGuard";
import HomePage from "@/pages/index"; import HomePage from "@/pages/index";
import StyleGuidePage from "@/pages/style-guide"; import StyleGuidePage from "@/pages/style-guide";
import LoginPage from "@/pages/auth/login";
import RegisterPage from "@/pages/auth/register";
import ForgotPasswordPage from "@/pages/auth/forgot-password";
import ResetPasswordPage from "@/pages/auth/reset-password";
import ProfilePage from "@/pages/profile/index";
const AuthInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { setAuth, clearAuth, setInitialized } = useAuthStore();
useEffect(() => {
const initAuth = async () => {
try {
const response = await authService.getMe();
if (response.success && response.data) {
// Access tokens are primarily managed via HTTP-only cookies in development/production
setAuth(response.data, "", "");
} else {
clearAuth();
}
} catch (error) {
clearAuth();
} finally {
setInitialized(true);
}
};
initAuth();
}, [setAuth, clearAuth, setInitialized]);
return <>{children}</>;
};
const Layout = () => { const Layout = () => {
return ( return (
<App theme={getSystemInfo().zaloTheme as AppProps["theme"]}> <App theme={getSystemInfo().zaloTheme as AppProps["theme"]}>
<SnackbarProvider> <QueryClientProvider client={queryClient}>
<ZMPRouter> <SnackbarProvider>
<AnimationRoutes> <ZMPRouter>
<Route path="/" element={<HomePage />}></Route> <AuthInitializer>
<Route path="/style-guide" element={<StyleGuidePage />}></Route> <AnimationRoutes>
</AnimationRoutes> {/* Public Auth Routes */}
</ZMPRouter> <Route path="/login" element={<LoginPage />}></Route>
</SnackbarProvider> <Route path="/register" element={<RegisterPage />}></Route>
<Route path="/forgot-password" element={<ForgotPasswordPage />}></Route>
<Route path="/reset-password" element={<ResetPasswordPage />}></Route>
{/* Private Protected Routes */}
<Route path="/" element={<AuthGuard><HomePage /></AuthGuard>}></Route>
<Route path="/profile" element={<AuthGuard><ProfilePage /></AuthGuard>}></Route>
<Route path="/style-guide" element={<AuthGuard><StyleGuidePage /></AuthGuard>}></Route>
</AnimationRoutes>
</AuthInitializer>
</ZMPRouter>
</SnackbarProvider>
</QueryClientProvider>
</App> </App>
); );
}; };
......
import React, { useEffect } from "react";
import { useNavigate } from "zmp-ui";
import { useAuthStore } from "@/stores/auth-store";
export const AuthGuard: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated, isInitialized } = useAuthStore();
const navigate = useNavigate();
useEffect(() => {
if (isInitialized && !isAuthenticated) {
navigate("/login", { replace: true });
}
}, [isInitialized, isAuthenticated, navigate]);
if (!isInitialized) {
return (
<div className="page flex flex-col items-center justify-center min-h-screen">
<div className="w-16 h-16 border-4 border-clay-primary border-t-transparent rounded-full animate-spin shadow-clay-raised"></div>
<p className="clay-caption mt-4 animate-pulse text-clay-primary">Đang tải cấu hình...</p>
</div>
);
}
if (!isAuthenticated) {
return null;
}
return <>{children}</>;
};
...@@ -5,40 +5,39 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> ...@@ -5,40 +5,39 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
error?: string; error?: string;
} }
export const Input: React.FC<InputProps> = ({ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
label, ({ label, error, className = "", id, ...props }, ref) => {
error, const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
className = "",
id,
...props
}) => {
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{label && ( {label && (
<label htmlFor={inputId} className="font-nunito font-semibold text-sm text-clay-text px-1"> <label htmlFor={inputId} className="font-nunito font-semibold text-sm text-clay-text px-1">
{label} {label}
</label> </label>
)} )}
<input <input
id={inputId} id={inputId}
className={` ref={ref}
bg-clay-bg text-clay-text font-nunito text-base px-4 py-3 className={`
rounded-clay-sm shadow-clay-pressed border border-transparent bg-clay-bg text-clay-text font-nunito text-base px-4 py-3
transition-all duration-150 ease-in-out placeholder-clay-text-muted/65 rounded-clay-sm shadow-clay-pressed border border-transparent
focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20 transition-all duration-150 ease-in-out placeholder-clay-text-muted/65
disabled:opacity-60 disabled:cursor-not-allowed focus:outline-none focus:border-clay-primary focus:ring-2 focus:ring-clay-primary/20
${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""} disabled:opacity-60 disabled:cursor-not-allowed
${className} ${error ? "border-clay-expense focus:border-clay-expense focus:ring-clay-expense/20" : ""}
`} ${className}
{...props} `}
/> {...props}
{error && ( />
<span className="font-nunito text-xs text-clay-expense px-1"> {error && (
{error} <span className="font-nunito text-xs text-clay-expense px-1">
</span> {error}
)} </span>
</div> )}
); </div>
}; );
}
);
Input.displayName = "Input";
import axios from "axios";
import { useAuthStore } from "@/stores/auth-store";
// Create an Axios instance
export const apiClient = axios.create({
baseURL: "http://localhost:7777/api/v1",
timeout: 10000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
// A separate instance for refreshing tokens to avoid interceptor loops
const refreshClient = axios.create({
baseURL: "http://localhost:7777/api/v1",
timeout: 10000,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
});
// Request interceptor to attach bearer token
apiClient.interceptors.request.use(
(config) => {
const accessToken = useAuthStore.getState().accessToken || localStorage.getItem("accessToken");
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle token refresh
let isRefreshing = false;
let failedQueue: any[] = [];
const processQueue = (error: any, token: string | null = null) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
}
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
// Check if the error is 401 and the request hasn't been retried yet
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// If already refreshing, wait for it to finish and then retry
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string | null) => {
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
resolve(apiClient(originalRequest));
},
reject: (err: any) => {
reject(err);
},
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Try refreshing the token (cookies will be sent automatically)
const response = await refreshClient.post("/auth/refresh", {});
if (response.data?.success) {
const newData = response.data?.data;
let newAccessToken = null;
if (newData && newData.accessToken) {
newAccessToken = newData.accessToken;
useAuthStore.getState().setAuth(
useAuthStore.getState().user,
newAccessToken,
newData.refreshToken
);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
processQueue(null, newAccessToken);
isRefreshing = false;
return apiClient(originalRequest);
} else {
throw new Error("Refresh failed");
}
} catch (refreshError) {
processQueue(refreshError, null);
isRefreshing = false;
useAuthStore.getState().clearAuth();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
});
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const forgotPasswordSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
});
type ForgotPasswordFormValues = z.infer<typeof forgotPasswordSchema>;
const ForgotPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isSent, setIsSent] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ForgotPasswordFormValues>({
resolver: zodResolver(forgotPasswordSchema),
});
const onSubmit = async (values: ForgotPasswordFormValues) => {
setIsLoading(true);
try {
const response = await authService.forgotPassword(values);
if (response.success) {
openSnackbar({
type: "success",
text: "Liên kết khôi phục mật khẩu đã được gửi đến email của bạn! ✉️",
});
setIsSent(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Gửi yêu cầu thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Gửi yêu cầu thất bại, vui lòng thử lại.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Quên Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Quên Mật Khẩu?</h1>
<p className="clay-caption">Đừng lo lắng, hãy nhập email của bạn để bắt đầu khôi phục</p>
</div>
{isSent ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-info/20 text-clay-info rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Đã gửi Email thành công</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi hướng dẫn đặt lại mật khẩu đến email của bạn. Vui lòng kiểm tra hộp thư đến (và cả hộp thư rác).
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
Nhập mã đặt lại mật khẩu
</Button>
</Card>
) : (
<>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email liên kết"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang gửi...</span>
</div>
) : (
"Gửi Yêu Cầu"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Nhớ mật khẩu?{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</>
)}
</div>
</Page>
);
};
export default ForgotPasswordPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { IconGradients } from "@/components/ui/icons";
const loginSchema = z.object({
email: z.string().min(1, "Email không được để trống").email("Email không hợp lệ"),
password: z.string().min(8, "Mật khẩu phải từ 8 ký tự trở lên"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (values: LoginFormValues) => {
setIsLoading(true);
try {
const response = await authService.login(values);
if (response.success && response.data) {
setAuth(response.data.user, "", "");
openSnackbar({
type: "success",
text: "Đăng nhập thành công! 🎉",
});
navigate("/", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng nhập thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Đăng nhập thất bại, vui lòng kiểm tra lại thông tin.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Nhập" showBackIcon={false} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Chào Mừng!</h1>
<p className="clay-caption">Đăng nhập tài khoản FinWise của bạn để bắt đầu</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Email"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
label="Mật khẩu"
placeholder="••••••••"
type="password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
/>
<div className="text-right">
<span
onClick={() => navigate("/forgot-password")}
className="font-nunito text-xs text-clay-primary font-semibold hover:underline cursor-pointer"
>
Quên mật khẩu?
</span>
</div>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng nhập...</span>
</div>
) : (
"Đăng Nhập"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Chưa có tài khoản?{" "}
<span
onClick={() => navigate("/register")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng ký ngay
</span>
</div>
</div>
</Page>
);
};
export default LoginPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const registerSchema = z
.object({
fullName: z.string().min(2, "Họ và tên phải từ 2 ký tự trở lên"),
email: z
.string()
.min(1, "Email không được để trống")
.email("Email không hợp lệ")
.refine((val) => val.endsWith("@gmail.com"), {
message: "Chỉ chấp nhận địa chỉ email @gmail.com",
}),
password: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"],
});
type RegisterFormValues = z.infer<typeof registerSchema>;
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (values: RegisterFormValues) => {
setIsLoading(true);
try {
const response = await authService.register({
fullName: values.fullName,
email: values.email,
password: values.password,
});
if (response.success) {
openSnackbar({
type: "success",
text: "Đăng ký thành công! Vui lòng kiểm tra email để kích hoạt.",
});
setIsRegistered(true);
} else {
openSnackbar({
type: "error",
text: response.message || "Đăng ký thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Đăng ký thất bại, vui lòng thử lại.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đăng Ký" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Tạo Tài Khoản</h1>
<p className="clay-caption">Khởi đầu hành trình quản lý tài chính thông minh</p>
</div>
{isRegistered ? (
<Card className="text-center space-y-4">
<div className="w-16 h-16 bg-clay-income/20 text-clay-income rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-white">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 19v-8.93a2 2 0 01.89-1.664l8-4.666a2 2 0 012.22 0l8 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-2.25-1.5a2 2 0 00-2.5 0l-2.25 1.5" />
</svg>
</div>
<h3 className="clay-title-h3 text-clay-primary">Kiểm tra Email của bạn</h3>
<p className="clay-body text-sm">
Chúng tôi đã gửi một liên kết kích hoạt đến email của bạn. Vui lòng nhấp vào liên kết để kích hoạt tài khoản trước khi đăng nhập.
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/login")}>
Quay lại Đăng Nhập
</Button>
</Card>
) : (
<>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Họ và tên"
placeholder="Nguyễn Văn A"
type="text"
error={errors.fullName?.message}
{...register("fullName")}
disabled={isLoading}
/>
<Input
label="Email (chỉ nhận @gmail.com)"
placeholder="user@gmail.com"
type="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
label="Mật khẩu"
placeholder="••••••••"
type="password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
/>
<Input
label="Xác nhận mật khẩu"
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang đăng ký...</span>
</div>
) : (
"Đăng Ký"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Đã có tài khoản?{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</>
)}
</div>
</Page>
);
};
export default RegisterPage;
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
const resetPasswordSchema = z
.object({
token: z.string().uuid("Mã khôi phục không hợp lệ (phải ở định dạng UUID)"),
newPassword: z
.string()
.min(8, "Mật khẩu phải từ 8 ký tự trở lên")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"],
});
type ResetPasswordFormValues = z.infer<typeof resetPasswordSchema>;
const ResetPasswordPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetPasswordFormValues>({
resolver: zodResolver(resetPasswordSchema),
});
const onSubmit = async (values: ResetPasswordFormValues) => {
setIsLoading(true);
try {
const response = await authService.resetPassword({
token: values.token,
newPassword: values.newPassword,
});
if (response.success) {
openSnackbar({
type: "success",
text: "Đặt lại mật khẩu thành công! Bạn có thể đăng nhập ngay.",
});
navigate("/login", { replace: true });
} else {
openSnackbar({
type: "error",
text: response.message || "Đặt lại mật khẩu thất bại",
});
}
} catch (error: any) {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Mã token không hợp lệ hoặc đã hết hạn.",
});
} finally {
setIsLoading(false);
}
};
return (
<Page className="page flex flex-col justify-center py-12">
<Header title="Đặt Lại Mật Khẩu" showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2">
<div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">Đặt Lại Mật Khẩu</h1>
<p className="clay-caption">Nhập mã đặt lại nhận được trong email và mật khẩu mới của bạn</p>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
label="Mã khôi phục (Token UUID)"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
type="text"
error={errors.token?.message}
{...register("token")}
disabled={isLoading}
/>
<Input
label="Mật khẩu mới"
placeholder="••••••••"
type="password"
error={errors.newPassword?.message}
{...register("newPassword")}
disabled={isLoading}
/>
<Input
label="Xác nhận mật khẩu mới"
placeholder="••••••••"
type="password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
<span>Đang xử lý...</span>
</div>
) : (
"Xác Nhận Đổi Mật Khẩu"
)}
</Button>
</form>
</Card>
<div className="text-center text-sm font-nunito text-clay-text-muted">
Quay lại{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
>
Đăng nhập
</span>
</div>
</div>
</Page>
);
};
export default ResetPasswordPage;
...@@ -3,10 +3,12 @@ import { Page, Header, useNavigate } from "zmp-ui"; ...@@ -3,10 +3,12 @@ import { Page, Header, useNavigate } from "zmp-ui";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { useAuthStore } from "@/stores/auth-store";
import { AIAssistantIcon, IconGradients } from "@/components/ui/icons"; import { AIAssistantIcon, IconGradients } from "@/components/ui/icons";
function HomePage() { function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuthStore();
return ( return (
<Page className="page flex flex-col justify-between py-8"> <Page className="page flex flex-col justify-between py-8">
...@@ -16,7 +18,7 @@ function HomePage() { ...@@ -16,7 +18,7 @@ function HomePage() {
<div className="flex-1 flex flex-col items-center justify-center gap-6 px-4"> <div className="flex-1 flex flex-col items-center justify-center gap-6 px-4">
{/* Logo/Avatar Area */} {/* Logo/Avatar Area */}
<div className="relative"> <div className="relative">
<Avatar size="lg" className="border-clay-primary" /> <Avatar size="lg" src={user?.avatarUrl || ""} className="border-clay-primary shadow-clay-hover" />
<div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white"> <div className="absolute -bottom-2 -right-2 bg-clay-primary text-white p-2 rounded-full shadow-clay-raised border border-white">
<AIAssistantIcon size={20} /> <AIAssistantIcon size={20} />
</div> </div>
...@@ -24,27 +26,34 @@ function HomePage() { ...@@ -24,27 +26,34 @@ function HomePage() {
{/* Text Area */} {/* Text Area */}
<div className="text-center space-y-2"> <div className="text-center space-y-2">
<h1 className="clay-title-h1 text-clay-primary">FinWise</h1> <h1 className="clay-title-h1 text-clay-primary">Chào, {user?.fullName || "Người dùng"}!</h1>
<h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2> <h2 className="clay-title-h3 text-clay-text">Sổ tay Chi tiêu & Báo cáo Tài chính</h2>
<p className="clay-caption max-w-xs mx-auto"> <p className="clay-caption max-w-xs mx-auto">
Ứng dụng quản lý tài chính cá nhân thông minh tích hợp Trợ lý AI trên nền tảng Zalo Mini App. Tài khoản: <span className="font-semibold text-clay-primary">{user?.email}</span>
</p> </p>
</div> </div>
{/* Info Card */} {/* Info Card */}
<Card className="w-full max-w-sm"> <Card className="w-full max-w-sm">
<h3 className="font-baloo font-bold text-clay-text text-base mb-1">Thiết kế Claymorphism</h3> <h3 className="font-baloo font-bold text-clay-text text-base mb-1">Xác thực hệ thống</h3>
<p className="text-xs text-clay-text-muted"> <p className="text-xs text-clay-text-muted">
Giao diện đã được cấu hình toàn bộ theo phong cách đất sét 3D mềm mại, bo góc bo tròn chunky và bóng đổ kép dịu mắt. Bạn đã đăng nhập thành công. Các API tiếp theo sẽ tự động được xác thực nhờ cookie an toàn và cơ chế JWT.
</p> </p>
</Card> </Card>
</div> </div>
{/* Navigation CTA */} {/* Navigation CTA */}
<div className="px-4 w-full max-w-sm mx-auto"> <div className="px-4 w-full max-w-sm mx-auto flex flex-col gap-3">
<Button <Button
variant="primary" variant="primary"
fullWidth fullWidth
onClick={() => navigate("/profile")}
>
Trang cá nhân của tôi 👤
</Button>
<Button
variant="secondary"
fullWidth
onClick={() => navigate("/style-guide")} onClick={() => navigate("/style-guide")}
> >
Khám phá Style Guide 🚀 Khám phá Style Guide 🚀
......
import React, { useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { Avatar } from "@/components/ui/Avatar";
import { Tabs } from "@/components/ui/Tabs";
import { Badge } from "@/components/ui/Badge";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
// Zod schemas for forms
const profileSchema = z.object({
fullName: z.string().min(2, "Họ và tên phải từ 2 ký tự trở lên"),
phoneNumber: z
.string()
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, "Số điện thoại không hợp lệ (ví dụ: 0912345678)")
.or(z.literal("")),
avatarUrl: z.string().url("Đường dẫn ảnh đại diện không hợp lệ").or(z.literal("")),
});
const passwordSchema = z
.object({
oldPassword: z.string().min(1, "Vui lòng nhập mật khẩu hiện tại"),
newPassword: z
.string()
.min(8, "Mật khẩu mới phải từ 8 ký tự trở lên")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"Mật khẩu phải chứa ít nhất 1 chữ hoa, 1 chữ thường và 1 chữ số"
),
confirmPassword: z.string().min(1, "Vui lòng xác nhận mật khẩu mới"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"],
});
type ProfileFormValues = z.infer<typeof profileSchema>;
type PasswordFormValues = z.infer<typeof passwordSchema>;
const ProfilePage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore();
const [activeTab, setActiveTab] = useState("profile");
const tabs = [
{ key: "profile", label: "Thông Tin" },
{ key: "password", label: "Mật Khẩu" },
{ key: "sessions", label: "Thiết Bị" },
];
// Forms setup
const {
register: registerProfile,
handleSubmit: handleProfileSubmit,
formState: { errors: profileErrors },
reset: resetProfileForm,
} = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
defaultValues: {
fullName: user?.fullName || "",
phoneNumber: user?.phoneNumber || "",
avatarUrl: user?.avatarUrl || "",
},
});
const {
register: registerPassword,
handleSubmit: handlePasswordSubmit,
formState: { errors: passwordErrors },
reset: resetPasswordForm,
} = useForm<PasswordFormValues>({
resolver: zodResolver(passwordSchema),
});
// React Query for active sessions list
const { data: sessionsResponse, isLoading: sessionsLoading, error: sessionsError } = useQuery({
queryKey: ["sessions"],
queryFn: authService.getSessions,
enabled: activeTab === "sessions",
});
// Mutations
const updateProfileMutation = useMutation({
mutationFn: authService.updateProfile,
onSuccess: async () => {
openSnackbar({
type: "success",
text: "Cập nhật thông tin cá nhân thành công! 🎉",
});
// Refetch user data
try {
const meRes = await authService.getMe();
if (meRes.success && meRes.data) {
setUser(meRes.data);
resetProfileForm({
fullName: meRes.data.fullName || "",
phoneNumber: meRes.data.phoneNumber || "",
avatarUrl: meRes.data.avatarUrl || "",
});
}
} catch (err) {
console.error("Refetch user profile failed", err);
}
},
onError: (error: any) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Cập nhật thất bại, vui lòng thử lại.",
});
},
});
const updatePasswordMutation = useMutation({
mutationFn: authService.updatePassword,
onSuccess: () => {
openSnackbar({
type: "success",
text: "Thay đổi mật khẩu thành công! 🔑",
});
resetPasswordForm();
},
onError: (error: any) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Thay đổi mật khẩu thất bại.",
});
},
});
const revokeSessionMutation = useMutation({
mutationFn: authService.revokeSession,
onSuccess: () => {
openSnackbar({
type: "success",
text: "Đã đóng phiên đăng nhập thành công.",
});
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
onError: (error: any) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Không thể đóng phiên đăng nhập.",
});
},
});
const revokeOtherSessionsMutation = useMutation({
mutationFn: authService.revokeOtherSessions,
onSuccess: () => {
openSnackbar({
type: "success",
text: "Đã đăng xuất khỏi tất cả các thiết bị khác thành công! 📱",
});
queryClient.invalidateQueries({ queryKey: ["sessions"] });
},
onError: (error: any) => {
openSnackbar({
type: "error",
text: error.response?.data?.message || "Không thể đăng xuất các thiết bị khác.",
});
},
});
const handleLogout = async () => {
try {
await authService.logout();
openSnackbar({
type: "success",
text: "Đăng xuất thành công. Hẹn gặp lại bạn! 👋",
});
} catch (err) {
// Still clear local auth state if logout call fails (e.g. server down)
} finally {
clearAuth();
navigate("/login", { replace: true });
}
};
const onProfileSubmit = (values: ProfileFormValues) => {
updateProfileMutation.mutate(values);
};
const onPasswordSubmit = (values: PasswordFormValues) => {
updatePasswordMutation.mutate({
oldPassword: values.oldPassword,
newPassword: values.newPassword,
});
};
return (
<Page className="page">
<Header title="Tài Khoản" showBackIcon={true} onBackClick={() => navigate("/")} />
<IconGradients />
<div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-white/50">
<Avatar size="lg" src={user?.avatarUrl || ""} className="border-clay-primary shadow-clay-hover" />
<div className="flex-1 min-w-0">
<h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || "Chưa thiết lập"}</h2>
<p className="clay-caption truncate">{user?.email}</p>
<div className="mt-1 flex gap-2">
<Badge type="primary">{user?.role?.name || "USER"}</Badge>
{user?.isActive && <Badge type="income">Đang hoạt động</Badge>}
</div>
</div>
</Card>
{/* Navigation Tabs */}
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
{/* Tab contents */}
{activeTab === "profile" && (
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Thông tin cá nhân</h3>
<form onSubmit={handleProfileSubmit(onProfileSubmit)} className="flex flex-col gap-4">
<Input
label="Họ và tên"
placeholder="Nhập họ và tên..."
error={profileErrors.fullName?.message}
{...registerProfile("fullName")}
disabled={updateProfileMutation.isPending}
/>
<Input
label="Số điện thoại"
placeholder="Nhập số điện thoại (ví dụ: 0912345678)..."
error={profileErrors.phoneNumber?.message}
{...registerProfile("phoneNumber")}
disabled={updateProfileMutation.isPending}
/>
<Input
label="Đường dẫn ảnh đại diện (Avatar URL)"
placeholder="Nhập liên kết ảnh..."
error={profileErrors.avatarUrl?.message}
{...registerProfile("avatarUrl")}
disabled={updateProfileMutation.isPending}
/>
<Button
variant="primary"
type="submit"
fullWidth
disabled={updateProfileMutation.isPending}
className="mt-2"
>
{updateProfileMutation.isPending ? "Đang lưu..." : "Cập Nhật Thông Tin"}
</Button>
</form>
</Card>
)}
{activeTab === "password" && (
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Đổi mật khẩu</h3>
<form onSubmit={handlePasswordSubmit(onPasswordSubmit)} className="flex flex-col gap-4">
<Input
label="Mật khẩu hiện tại"
placeholder="Nhập mật khẩu hiện tại..."
type="password"
error={passwordErrors.oldPassword?.message}
{...registerPassword("oldPassword")}
disabled={updatePasswordMutation.isPending}
/>
<Input
label="Mật khẩu mới"
placeholder="Mật khẩu mới ít nhất 8 ký tự..."
type="password"
error={passwordErrors.newPassword?.message}
{...registerPassword("newPassword")}
disabled={updatePasswordMutation.isPending}
/>
<Input
label="Xác nhận mật khẩu mới"
placeholder="Nhập lại mật khẩu mới..."
type="password"
error={passwordErrors.confirmPassword?.message}
{...registerPassword("confirmPassword")}
disabled={updatePasswordMutation.isPending}
/>
<Button
variant="primary"
type="submit"
fullWidth
disabled={updatePasswordMutation.isPending}
className="mt-2"
>
{updatePasswordMutation.isPending ? "Đang cập nhật..." : "Đổi Mật Khẩu"}
</Button>
</form>
</Card>
)}
{activeTab === "sessions" && (
<div className="flex flex-col gap-4">
{/* Revoke All Other Devices Option */}
<Card className="flex flex-col gap-3">
<h3 className="clay-title-h3 text-clay-primary-dark">Quản lý phiên đăng nhập</h3>
<p className="clay-caption">
Bảo vệ tài khoản bằng cách đăng xuất khỏi tất cả các thiết bị và trình duyệt khác mà bạn đã đăng nhập trước đó.
</p>
<Button
variant="secondary"
onClick={() => revokeOtherSessionsMutation.mutate()}
disabled={revokeOtherSessionsMutation.isPending}
fullWidth
>
{revokeOtherSessionsMutation.isPending ? "Đang xử lý..." : "Đăng xuất các thiết bị khác"}
</Button>
</Card>
{/* List of active sessions */}
<Card>
<h3 className="clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2">Thiết bị đang hoạt động</h3>
{sessionsLoading && (
<div className="flex flex-col gap-3">
{[1, 2].map((i) => (
<div key={i} className="animate-pulse bg-clay-bg h-16 w-full rounded-clay-sm"></div>
))}
</div>
)}
{sessionsError && (
<div className="text-center py-4">
<span className="text-clay-expense text-sm font-semibold">
Không thể tải danh sách phiên. Vui lòng thử lại.
</span>
</div>
)}
{sessionsResponse?.success && sessionsResponse.data && (
<div className="flex flex-col gap-4 divide-y divide-clay-text/5">
{sessionsResponse.data.map((session, idx) => (
<div
key={session.id}
className={`flex justify-between items-start pt-3 ${idx === 0 ? "pt-0" : ""}`}
>
<div className="flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-nunito font-bold text-sm text-clay-text truncate">
{session.deviceName || "Thiết bị không tên"}
</span>
{session.isCurrent && (
<span className="bg-clay-income/25 text-clay-income text-[10px] font-bold px-2 py-0.5 rounded-full border border-clay-income/20">
Thiết bị này
</span>
)}
</div>
<p className="clay-caption text-[11px] mt-0.5">IP: {session.ipAddress}</p>
<p className="clay-caption text-[11px]">
Hoạt động từ: {new Date(session.createdAt).toLocaleString()}
</p>
</div>
{!session.isCurrent && (
<button
onClick={() => revokeSessionMutation.mutate(session.id)}
disabled={revokeSessionMutation.isPending}
className="text-xs font-nunito font-semibold text-clay-expense hover:underline bg-transparent border-none p-1 focus:outline-none disabled:opacity-50"
>
Đăng xuất
</button>
)}
</div>
))}
{sessionsResponse.data.length === 0 && (
<p className="text-center clay-caption py-4">Không có phiên đăng nhập nào khác.</p>
)}
</div>
)}
</Card>
</div>
)}
{/* Global Logout Button */}
<div className="px-4">
<Button variant="secondary" onClick={handleLogout} fullWidth className="text-clay-expense">
Đăng Xuất Tài Khoản
</Button>
</div>
</div>
</Page>
);
};
export default ProfilePage;
import { apiClient } from "@/lib/api-client";
import { ApiResponse, User, Session } from "@/types/auth";
export const authService = {
async register(data: any): Promise<ApiResponse> {
const response = await apiClient.post("/auth/register", data);
return response.data;
},
async verifyEmail(token: string): Promise<ApiResponse> {
const response = await apiClient.get(`/auth/verify-email?token=${token}`);
return response.data;
},
async login(data: any): Promise<ApiResponse<{ user: User }>> {
const response = await apiClient.post("/auth/login", data);
return response.data;
},
async getMe(): Promise<ApiResponse<User>> {
const response = await apiClient.get("/auth/me");
return response.data;
},
async logout(): Promise<ApiResponse> {
const response = await apiClient.post("/auth/logout", {});
return response.data;
},
async updateProfile(data: any): Promise<ApiResponse> {
const response = await apiClient.put("/auth/profile", data);
return response.data;
},
async updatePassword(data: any): Promise<ApiResponse> {
const response = await apiClient.put("/auth/password", data);
return response.data;
},
async forgotPassword(data: { email: string }): Promise<ApiResponse> {
const response = await apiClient.post("/auth/forgot-password", data);
return response.data;
},
async resetPassword(data: any): Promise<ApiResponse> {
const response = await apiClient.post("/auth/reset-password", data);
return response.data;
},
async getSessions(): Promise<ApiResponse<Session[]>> {
const response = await apiClient.get("/auth/sessions");
return response.data;
},
async revokeSession(id: string): Promise<ApiResponse> {
const response = await apiClient.delete(`/auth/sessions/${id}`);
return response.data;
},
async revokeOtherSessions(): Promise<ApiResponse> {
const response = await apiClient.delete("/auth/sessions");
return response.data;
},
};
import { create } from "zustand";
import { User } from "@/types/auth";
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
setAuth: (user: User | null, accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
setUser: (user: User | null) => void;
setInitialized: (initialized: boolean) => void;
}
export const useAuthStore = create<AuthState>((set) => {
// Pre-load tokens from localStorage
const accessToken = localStorage.getItem("accessToken");
const refreshToken = localStorage.getItem("refreshToken");
return {
user: null,
accessToken,
refreshToken,
isAuthenticated: !!accessToken,
isInitialized: false,
setAuth: (user, accessToken, refreshToken) => {
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("refreshToken", refreshToken);
set({
user,
accessToken,
refreshToken,
isAuthenticated: true,
});
},
clearAuth: () => {
localStorage.removeItem("accessToken");
localStorage.removeItem("refreshToken");
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
});
},
setUser: (user) => {
set({ user });
},
setInitialized: (isInitialized) => {
set({ isInitialized });
},
};
});
export interface Role {
id: string;
name: 'ADMIN' | 'MANAGER' | 'USER';
}
export interface User {
id: string;
email: string;
fullName: string | null;
avatarUrl?: string | null;
phoneNumber?: string | null;
roleId: string;
role: Role;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export interface Session {
id: string;
deviceName: string;
ipAddress: string;
createdAt: string;
isCurrent: boolean;
}
export interface ApiResponse<T = any> {
success: boolean;
message: string;
data: T;
errors?: any[] | null;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment