Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
F
finwise-miniapp-fe
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ThinhNC
finwise-miniapp-fe
Commits
7f6b0ec4
Commit
7f6b0ec4
authored
Aug 25, 2026
by
ThinhNC
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(profile): enhance user profile UI, add session pagination, and optimize dirty checks
parent
4d439196
Changes
11
Hide whitespace changes
Inline
Side-by-side
Showing
11 changed files
with
313 additions
and
69 deletions
+313
-69
icons.tsx
src/components/ui/icons.tsx
+9
-0
en.json
src/i18n/locales/en.json
+11
-5
vi.json
src/i18n/locales/vi.json
+12
-6
clipboard.ts
src/lib/clipboard.ts
+47
-0
error-message.ts
src/lib/error-message.ts
+4
-1
index.tsx
src/pages/admin/roles/index.tsx
+32
-2
OneTimeSecretModal.tsx
src/pages/integrations/components/OneTimeSecretModal.tsx
+3
-6
index.tsx
src/pages/profile/index.tsx
+153
-37
auth.service.ts
src/services/auth.service.ts
+13
-3
upload.service.ts
src/services/upload.service.ts
+12
-7
auth.ts
src/types/auth.ts
+17
-2
No files found.
src/components/ui/icons.tsx
View file @
7f6b0ec4
...
...
@@ -307,4 +307,13 @@ export const SendIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</
svg
>
);
// Copy
export
const
CopyIcon
:
React
.
FC
<
IconProps
>
=
({
size
=
16
,
...
props
})
=>
(
<
svg
width=
{
size
}
height=
{
size
}
viewBox=
"0 0 24 24"
fill=
"none"
stroke=
"currentColor"
strokeWidth=
"2.5"
strokeLinecap=
"round"
strokeLinejoin=
"round"
{
...
props
}
>
<
rect
x=
"9"
y=
"9"
width=
"13"
height=
"13"
rx=
"2"
ry=
"2"
/>
<
path
d=
"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
/>
</
svg
>
);
src/i18n/locales/en.json
View file @
7f6b0ec4
...
...
@@ -158,7 +158,7 @@
"submit"
:
"Sign In"
,
"noAccount"
:
"Don't have an account?"
,
"registerNow"
:
"Sign up now"
,
"success"
:
"Signed in successfully!
🎉
"
,
"success"
:
"Signed in successfully!"
,
"failed"
:
"Sign-in failed"
,
"failedDetail"
:
"Sign-in failed. Please check your details and try again."
},
...
...
@@ -190,7 +190,7 @@
"submit"
:
"Send Request"
,
"rememberPassword"
:
"Remember your password?"
,
"login"
:
"Sign in"
,
"successToast"
:
"A password recovery link was sent to your email!
✉️
"
,
"successToast"
:
"A password recovery link was sent to your email!"
,
"failed"
:
"Could not send request"
,
"failedDetail"
:
"Could not send the request. Please try again."
,
"sentTitle"
:
"Email Sent"
,
...
...
@@ -244,6 +244,11 @@
"header"
:
"Account"
,
"editAvatar"
:
"Edit profile picture"
,
"notSet"
:
"Not set"
,
"userId"
:
"ID"
,
"createdAt"
:
"Created date"
,
"copyId"
:
"Copy ID"
,
"idCopied"
:
"Account ID copied!"
,
"copyIdFailed"
:
"Could not copy ID."
,
"tabs"
:
{
"info"
:
"Profile"
,
"password"
:
"Password"
,
"devices"
:
"Devices"
},
"personalInfo"
:
"Personal information"
,
"fullName"
:
"Full name"
,
...
...
@@ -261,14 +266,14 @@
"confirmNewPassword"
:
"Confirm new password"
,
"confirmNewPasswordPlaceholder"
:
"Enter the new password again..."
,
"passwordSubmit"
:
"Change Password"
,
"passwordSuccess"
:
"Password changed successfully!
🔑
"
,
"passwordSuccess"
:
"Password changed successfully!"
,
"passwordFailed"
:
"Could not change password."
,
"showPassword"
:
"Show password"
,
"hidePassword"
:
"Hide password"
,
"sessionManagement"
:
"Manage sign-in sessions"
,
"sessionDescription"
:
"Protect your account by signing out of all other devices and browsers you previously used."
,
"logoutOthers"
:
"Sign out other devices"
,
"logoutOthersSuccess"
:
"Signed out of all other devices!
📱
"
,
"logoutOthersSuccess"
:
"Signed out of all other devices!"
,
"logoutOthersFailed"
:
"Could not sign out other devices."
,
"activeDevices"
:
"Active devices"
,
"sessionsLoading"
:
"Loading session list"
,
...
...
@@ -279,10 +284,11 @@
"ipAddress"
:
"IP: {{address}}"
,
"logout"
:
"Sign Out"
,
"logoutDevice"
:
"Sign out"
,
"logoutSuccess"
:
"Signed out. See you next time!
👋
"
,
"logoutSuccess"
:
"Signed out. See you next time!"
,
"revokeSuccess"
:
"Session closed successfully."
,
"revokeFailed"
:
"Could not close the session."
,
"noSessions"
:
"There are no other sign-in sessions."
,
"paginationLabel"
:
"Device pagination"
,
"avatarSelectRequired"
:
"Choose a profile picture before saving."
,
"avatarSaveFailed"
:
"Could not save the profile picture to your profile."
,
"avatarSaveSuccess"
:
"Picture and crop position saved!"
,
...
...
src/i18n/locales/vi.json
View file @
7f6b0ec4
...
...
@@ -244,6 +244,11 @@
"header"
:
"Tài Khoản"
,
"editAvatar"
:
"Chỉnh ảnh đại diện"
,
"notSet"
:
"Chưa thiết lập"
,
"userId"
:
"ID"
,
"createdAt"
:
"Ngày tạo"
,
"copyId"
:
"Sao chép ID"
,
"idCopied"
:
"Đã sao chép ID tài khoản!"
,
"copyIdFailed"
:
"Không thể sao chép ID."
,
"tabs"
:
{
"info"
:
"Thông Tin"
,
"password"
:
"Mật Khẩu"
,
...
...
@@ -255,7 +260,7 @@
"phone"
:
"Số điện thoại"
,
"phonePlaceholder"
:
"Nhập số điện thoại..."
,
"update"
:
"Cập Nhật Thông Tin"
,
"updateSuccess"
:
"Cập nhật thông tin cá nhân thành công!
🎉
"
,
"updateSuccess"
:
"Cập nhật thông tin cá nhân thành công!"
,
"updateFailed"
:
"Cập nhật thất bại, vui lòng thử lại."
,
"changePassword"
:
"Đổi mật khẩu"
,
"currentPassword"
:
"Mật khẩu hiện tại"
,
...
...
@@ -265,14 +270,14 @@
"confirmNewPassword"
:
"Xác nhận mật khẩu mới"
,
"confirmNewPasswordPlaceholder"
:
"Nhập lại mật khẩu mới..."
,
"passwordSubmit"
:
"Đổi Mật Khẩu"
,
"passwordSuccess"
:
"Thay đổi mật khẩu thành công!
🔑
"
,
"passwordSuccess"
:
"Thay đổi mật khẩu thành công!"
,
"passwordFailed"
:
"Thay đổi mật khẩu thất bại."
,
"showPassword"
:
"Hiện mật khẩu"
,
"hidePassword"
:
"Ẩn mật khẩu"
,
"sessionManagement"
:
"Quản lý phiên đăng nhập"
,
"sessionDescription"
:
"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 đó."
,
"logoutOthers"
:
"Đăng xuất các thiết bị khác"
,
"logoutOthersSuccess"
:
"Đã đăng xuất khỏi tất cả các thiết bị khác thành công!
📱
"
,
"logoutOthersSuccess"
:
"Đã đăng xuất khỏi tất cả các thiết bị khác thành công!"
,
"logoutOthersFailed"
:
"Không thể đăng xuất các thiết bị khác."
,
"activeDevices"
:
"Thiết bị đang hoạt động"
,
"sessionsLoading"
:
"Đang tải danh sách phiên"
,
...
...
@@ -283,10 +288,11 @@
"ipAddress"
:
"IP: {{address}}"
,
"logout"
:
"Đăng Xuất"
,
"logoutDevice"
:
"Đăng xuất"
,
"logoutSuccess"
:
"Đăng xuất thành công. Hẹn gặp lại bạn!
👋
"
,
"logoutSuccess"
:
"Đăng xuất thành công. Hẹn gặp lại bạn!"
,
"revokeSuccess"
:
"Đã đóng phiên đăng nhập thành công."
,
"revokeFailed"
:
"Không thể đóng phiên đăng nhập."
,
"noSessions"
:
"Không có phiên đăng nhập nào khác."
,
"paginationLabel"
:
"Phân trang thiết bị"
,
"avatarSelectRequired"
:
"Vui lòng chọn ảnh đại diện trước khi lưu."
,
"avatarSaveFailed"
:
"Không thể lưu ảnh đại diện vào hồ sơ."
,
"avatarSaveSuccess"
:
"Đã lưu ảnh và vùng hiển thị!"
,
...
...
@@ -971,9 +977,9 @@
"emptyHint"
:
"Hãy tạo giao dịch đầu tiên hoặc thử thay đổi điều kiện lọc của bạn."
,
"loading"
:
"Đang tải danh sách giao dịch..."
,
"loadFailed"
:
"Tải giao dịch thất bại"
,
"createSuccess"
:
"Tạo giao dịch thành công!
🎉
"
,
"createSuccess"
:
"Tạo giao dịch thành công!"
,
"createFailed"
:
"Không thể tạo giao dịch. Vui lòng thử lại."
,
"updateSuccess"
:
"Cập nhật giao dịch thành công!
🎉
"
,
"updateSuccess"
:
"Cập nhật giao dịch thành công!"
,
"updateFailed"
:
"Không thể cập nhật giao dịch. Vui lòng thử lại."
,
"deleteSuccess"
:
"Xóa giao dịch thành công!"
,
"deleteFailed"
:
"Không thể xóa giao dịch. Vui lòng thử lại."
,
...
...
src/lib/clipboard.ts
0 → 100644
View file @
7f6b0ec4
/**
* Copies text to clipboard safely across modern browsers, iframes, webviews, and fallback environments.
*/
export
async
function
copyToClipboard
(
text
:
string
):
Promise
<
boolean
>
{
if
(
!
text
)
return
false
;
// 1. Try modern navigator.clipboard API if available
if
(
typeof
navigator
!==
"undefined"
&&
navigator
.
clipboard
&&
typeof
navigator
.
clipboard
.
writeText
===
"function"
)
{
try
{
await
navigator
.
clipboard
.
writeText
(
text
);
return
true
;
}
catch
(
err
)
{
console
.
warn
(
"navigator.clipboard.writeText failed, falling back to execCommand:"
,
err
);
}
}
// 2. Fallback using textarea + document.execCommand('copy')
try
{
const
textArea
=
document
.
createElement
(
"textarea"
);
textArea
.
value
=
text
;
textArea
.
setAttribute
(
"readonly"
,
""
);
textArea
.
style
.
position
=
"fixed"
;
textArea
.
style
.
top
=
"0"
;
textArea
.
style
.
left
=
"0"
;
textArea
.
style
.
width
=
"2em"
;
textArea
.
style
.
height
=
"2em"
;
textArea
.
style
.
padding
=
"0"
;
textArea
.
style
.
border
=
"none"
;
textArea
.
style
.
outline
=
"none"
;
textArea
.
style
.
boxShadow
=
"none"
;
textArea
.
style
.
background
=
"transparent"
;
textArea
.
style
.
opacity
=
"0"
;
textArea
.
style
.
zIndex
=
"-1"
;
document
.
body
.
appendChild
(
textArea
);
textArea
.
focus
();
textArea
.
select
();
textArea
.
setSelectionRange
(
0
,
text
.
length
);
const
successful
=
document
.
execCommand
(
"copy"
);
document
.
body
.
removeChild
(
textArea
);
return
successful
;
}
catch
(
err
)
{
console
.
error
(
"All copy to clipboard methods failed:"
,
err
);
return
false
;
}
}
src/lib/error-message.ts
View file @
7f6b0ec4
...
...
@@ -8,10 +8,13 @@ export function getErrorMessage(error: unknown, fallback: string): string {
if
(
typeof
serverMessage
===
"string"
&&
serverMessage
.
trim
().
length
>
0
)
{
return
serverMessage
;
}
if
(
error
.
message
&&
error
.
message
.
trim
().
length
>
0
)
{
return
error
.
message
;
}
return
fallback
;
}
if
(
error
instanceof
LocalizedError
&&
error
.
message
)
{
if
(
error
instanceof
Error
&&
error
.
message
&&
error
.
message
.
trim
().
length
>
0
)
{
return
error
.
message
;
}
...
...
src/pages/admin/roles/index.tsx
View file @
7f6b0ec4
...
...
@@ -168,6 +168,28 @@ const RolesPage: React.FC = () => {
},
});
// Dirty checks
const
initialPermissionIds
=
useMemo
(()
=>
{
if
(
!
selectedRole
)
return
[];
return
(
selectedRole
.
rolePermissions
||
[])
.
map
((
rp
)
=>
rp
.
permissionId
||
rp
.
permission
?.
id
||
''
)
.
filter
(
Boolean
);
},
[
selectedRole
]);
const
isMatrixDirty
=
useMemo
(()
=>
{
if
(
!
selectedRole
)
return
false
;
if
(
selectedPermissionIds
.
length
!==
initialPermissionIds
.
length
)
return
true
;
const
initialSet
=
new
Set
(
initialPermissionIds
);
return
selectedPermissionIds
.
some
((
id
)
=>
!
initialSet
.
has
(
id
));
},
[
selectedRole
,
selectedPermissionIds
,
initialPermissionIds
]);
const
isEditRoleDirty
=
useMemo
(()
=>
{
if
(
!
selectedRole
)
return
false
;
const
origName
=
selectedRole
.
name
||
''
;
const
origDesc
=
selectedRole
.
description
||
''
;
return
editRoleName
.
trim
().
toUpperCase
()
!==
origName
||
editRoleDesc
.
trim
()
!==
origDesc
;
},
[
selectedRole
,
editRoleName
,
editRoleDesc
]);
// Handlers
const
handleOpenMatrix
=
(
role
:
Role
)
=>
{
setSelectedRole
(
role
);
...
...
@@ -206,6 +228,10 @@ const RolesPage: React.FC = () => {
const
handleSaveMatrix
=
()
=>
{
if
(
!
selectedRole
)
return
;
if
(
!
isMatrixDirty
)
{
setIsMatrixModalOpen
(
false
);
return
;
}
assignPermissionsMutation
.
mutate
({
roleId
:
selectedRole
.
id
,
permissionIds
:
selectedPermissionIds
,
...
...
@@ -224,6 +250,10 @@ const RolesPage: React.FC = () => {
const
handleUpdateRole
=
(
e
:
React
.
FormEvent
)
=>
{
e
.
preventDefault
();
if
(
!
selectedRole
)
return
;
if
(
!
isEditRoleDirty
)
{
setIsEditModalOpen
(
false
);
return
;
}
updateRoleMutation
.
mutate
({
id
:
selectedRole
.
id
,
data
:
{
...
...
@@ -417,7 +447,7 @@ const RolesPage: React.FC = () => {
<
Button
variant=
"primary"
onClick=
{
handleSaveMatrix
}
disabled=
{
assignPermissionsMutation
.
isPending
}
disabled=
{
!
isMatrixDirty
||
assignPermissionsMutation
.
isPending
}
>
{
assignPermissionsMutation
.
isPending
?
t
(
'common.saving'
)
||
'Đang lưu...'
...
...
@@ -578,7 +608,7 @@ const RolesPage: React.FC = () => {
<
Button
variant=
"primary"
type=
"submit"
disabled=
{
updateRoleMutation
.
isPending
}
disabled=
{
!
isEditRoleDirty
||
updateRoleMutation
.
isPending
}
>
{
updateRoleMutation
.
isPending
?
t
(
'common.saving'
)
||
'Đang lưu...'
...
...
src/pages/integrations/components/OneTimeSecretModal.tsx
View file @
7f6b0ec4
...
...
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/Button";
import
{
Modal
}
from
"@/components/ui/Modal"
;
import
{
CheckIcon
,
KeyIcon
}
from
"@/components/ui/icons"
;
import
{
useI18n
}
from
"@/i18n"
;
import
{
copyToClipboard
}
from
"@/lib/clipboard"
;
interface
OneTimeSecretModalProps
{
isOpen
:
boolean
;
...
...
@@ -23,12 +24,8 @@ export const OneTimeSecretModal: React.FC<OneTimeSecretModalProps> = ({
useEffect
(()
=>
setCopied
(
false
),
[
isOpen
,
secret
]);
const
copySecret
=
async
()
=>
{
try
{
await
navigator
.
clipboard
.
writeText
(
secret
);
setCopied
(
true
);
}
catch
{
setCopied
(
false
);
}
const
success
=
await
copyToClipboard
(
secret
);
setCopied
(
success
);
};
return
(
...
...
src/pages/profile/index.tsx
View file @
7f6b0ec4
import
React
,
{
useMemo
,
useState
}
from
"react"
;
import
React
,
{
use
Effect
,
use
Memo
,
useState
}
from
"react"
;
import
{
Page
,
Header
,
useNavigate
,
useSnackbar
}
from
"zmp-ui"
;
import
{
useForm
}
from
"react-hook-form"
;
import
{
zodResolver
}
from
"@hookform/resolvers/zod"
;
...
...
@@ -11,17 +11,17 @@ 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
{
ThemeToggle
}
from
"@/components/ui/ThemeToggle"
;
import
{
useAuthStore
}
from
"@/stores/auth-store"
;
import
{
useThemeStore
}
from
"@/stores/theme-store"
;
import
{
authService
}
from
"@/services/auth.service"
;
import
{
EyeIcon
,
EyeOffIcon
,
IconGradients
}
from
"@/components/ui/icons"
;
import
{
uploadService
}
from
"@/services/upload.service"
;
import
{
CheckIcon
,
CopyIcon
,
EyeIcon
,
EyeOffIcon
,
IconGradients
}
from
"@/components/ui/icons"
;
import
{
AvatarEditorModal
,
AvatarPosition
,
}
from
"@/pages/profile/avatar-editor-modal"
;
import
{
TranslationFunction
,
useI18n
}
from
"@/i18n"
;
import
{
getErrorMessage
,
LocalizedError
}
from
"@/lib/error-message"
;
import
{
copyToClipboard
}
from
"@/lib/clipboard"
;
const
AVATAR_MAX_FILE_SIZE_BYTES
=
5
*
1024
*
1024
;
const
AVATAR_ACCEPT
=
"image/jpeg,image/png,image/webp"
;
...
...
@@ -33,7 +33,6 @@ const createProfileSchema = (t: TranslationFunction) => z.object({
.
string
()
.
regex
(
/^
(
0
[
3|5|7|8|9
])
+
([
0-9
]{8})
$/
,
t
(
"validation.phoneInvalid"
))
.
or
(
z
.
literal
(
""
)),
avatarUrl
:
z
.
string
().
url
(
t
(
"validation.avatarUrlInvalid"
)).
or
(
z
.
literal
(
""
)),
});
const
createPasswordSchema
=
(
t
:
TranslationFunction
)
=>
z
...
...
@@ -61,7 +60,6 @@ const ProfilePage: React.FC = () => {
const
{
openSnackbar
}
=
useSnackbar
();
const
queryClient
=
useQueryClient
();
const
{
user
,
setUser
,
clearAuth
}
=
useAuthStore
();
const
{
theme
,
setTheme
}
=
useThemeStore
();
const
{
formatDate
,
t
}
=
useI18n
();
const
profileSchema
=
useMemo
(()
=>
createProfileSchema
(
t
),
[
t
]);
const
passwordSchema
=
useMemo
(()
=>
createPasswordSchema
(
t
),
[
t
]);
...
...
@@ -80,6 +78,7 @@ const ProfilePage: React.FC = () => {
newPassword
:
false
,
confirmPassword
:
false
,
});
const
[
isIdCopied
,
setIsIdCopied
]
=
useState
(
false
);
const
tabs
=
[
{
key
:
"profile"
,
label
:
t
(
"profile.tabs.info"
)
},
...
...
@@ -87,24 +86,62 @@ const ProfilePage: React.FC = () => {
{
key
:
"sessions"
,
label
:
t
(
"profile.tabs.devices"
)
},
];
const
handleCopyId
=
async
()
=>
{
if
(
!
user
?.
id
)
return
;
const
success
=
await
copyToClipboard
(
user
.
id
);
if
(
success
)
{
setIsIdCopied
(
true
);
openSnackbar
({
type
:
"success"
,
text
:
t
(
"profile.idCopied"
),
});
setTimeout
(()
=>
{
setIsIdCopied
(
false
);
},
2000
);
}
else
{
openSnackbar
({
type
:
"error"
,
text
:
t
(
"profile.copyIdFailed"
),
});
}
};
// Forms setup
const
{
register
:
registerProfile
,
handleSubmit
:
handleProfileSubmit
,
formState
:
{
errors
:
profileErrors
},
reset
:
resetProfileForm
,
setValue
:
setProfileValue
,
watch
:
watchProfile
,
}
=
useForm
<
ProfileFormValues
>
({
resolver
:
zodResolver
(
profileSchema
),
defaultValues
:
{
fullName
:
user
?.
fullName
||
""
,
phoneNumber
:
user
?.
phoneNumber
||
""
,
avatarUrl
:
user
?.
avatarUrl
||
""
,
},
});
const
currentAvatarUrl
=
watchProfile
(
"avatarUrl"
);
useEffect
(()
=>
{
if
(
user
)
{
resetProfileForm
({
fullName
:
user
.
fullName
||
""
,
phoneNumber
:
user
.
phoneNumber
||
""
,
});
}
},
[
user
,
resetProfileForm
]);
const
watchedFullName
=
watchProfile
(
"fullName"
);
const
watchedPhoneNumber
=
watchProfile
(
"phoneNumber"
);
const
isProfileChanged
=
useMemo
(()
=>
{
const
origFullName
=
user
?.
fullName
||
""
;
const
origPhone
=
user
?.
phoneNumber
||
""
;
const
currFullName
=
(
watchedFullName
??
""
).
trim
();
const
currPhone
=
(
watchedPhoneNumber
??
""
).
trim
();
return
currFullName
!==
origFullName
||
currPhone
!==
origPhone
;
},
[
user
,
watchedFullName
,
watchedPhoneNumber
]);
const
{
register
:
registerPassword
,
...
...
@@ -115,10 +152,17 @@ const ProfilePage: React.FC = () => {
resolver
:
zodResolver
(
passwordSchema
),
});
const
[
sessionPage
,
setSessionPage
]
=
useState
(
1
);
// React Query for active sessions list
const
{
data
:
sessionsResponse
,
isLoading
:
sessionsLoading
,
error
:
sessionsError
}
=
useQuery
({
queryKey
:
[
"sessions"
],
queryFn
:
authService
.
getSessions
,
const
{
data
:
sessionsResponse
,
isLoading
:
sessionsLoading
,
isFetching
:
sessionsFetching
,
error
:
sessionsError
,
}
=
useQuery
({
queryKey
:
[
"sessions"
,
sessionPage
],
queryFn
:
()
=>
authService
.
getSessions
({
page
:
sessionPage
,
limit
:
5
}),
enabled
:
activeTab
===
"sessions"
,
});
...
...
@@ -135,7 +179,6 @@ const ProfilePage: React.FC = () => {
resetProfileForm
({
fullName
:
response
.
data
.
fullName
||
""
,
phoneNumber
:
response
.
data
.
phoneNumber
||
""
,
avatarUrl
:
response
.
data
.
avatarUrl
||
""
,
});
}
},
...
...
@@ -149,7 +192,7 @@ const ProfilePage: React.FC = () => {
const
saveAvatarMutation
=
useMutation
({
mutationFn
:
async
()
=>
{
let
avatarUrl
=
currentAvatarUrl
||
user
?.
avatarUrl
||
null
;
let
avatarUrl
=
user
?.
avatarUrl
||
null
;
if
(
avatarFile
)
{
const
upload
=
await
uploadService
.
uploadAvatar
(
avatarFile
);
avatarUrl
=
upload
.
publicUrl
;
...
...
@@ -158,7 +201,17 @@ const ProfilePage: React.FC = () => {
throw
new
LocalizedError
(
t
(
"profile.avatarSelectRequired"
));
}
const
response
=
await
authService
.
updateProfile
({
const
isAvatarUnchanged
=
!
avatarFile
&&
avatarUrl
===
user
?.
avatarUrl
&&
avatarPosition
.
x
===
(
user
?.
avatarPositionX
??
50
)
&&
avatarPosition
.
y
===
(
user
?.
avatarPositionY
??
50
);
if
(
isAvatarUnchanged
&&
user
)
{
return
user
;
}
const
response
=
await
authService
.
updateAvatar
({
avatarUrl
,
avatarPositionX
:
avatarPosition
.
x
,
avatarPositionY
:
avatarPosition
.
y
,
...
...
@@ -169,7 +222,6 @@ const ProfilePage: React.FC = () => {
return
response
.
data
;
},
onSuccess
:
(
updatedUser
)
=>
{
setProfileValue
(
"avatarUrl"
,
updatedUser
.
avatarUrl
||
""
,
{
shouldDirty
:
false
});
setUser
(
updatedUser
);
setAvatarFile
(
null
);
setAvatarPreviewUrl
(
null
);
...
...
@@ -180,6 +232,7 @@ const ProfilePage: React.FC = () => {
});
},
onError
:
(
error
:
unknown
)
=>
{
console
.
error
(
"Save avatar error:"
,
error
);
openSnackbar
({
type
:
"error"
,
text
:
getErrorMessage
(
error
,
t
(
"profile.avatarUploadFailed"
)),
...
...
@@ -189,14 +242,13 @@ const ProfilePage: React.FC = () => {
const
deleteAvatarMutation
=
useMutation
({
mutationFn
:
async
()
=>
{
const
response
=
await
authService
.
updateProfile
({
avatarUrl
:
null
}
);
const
response
=
await
authService
.
deleteAvatar
(
);
if
(
!
response
.
success
||
!
response
.
data
)
{
throw
new
LocalizedError
(
t
(
"profile.avatarDeleteFailed"
));
}
return
response
.
data
;
},
onSuccess
:
(
updatedUser
)
=>
{
setProfileValue
(
"avatarUrl"
,
""
,
{
shouldDirty
:
false
});
setUser
(
updatedUser
);
setAvatarFile
(
null
);
setAvatarPreviewUrl
(
null
);
...
...
@@ -244,6 +296,9 @@ const ProfilePage: React.FC = () => {
type
:
"success"
,
text
:
t
(
"profile.revokeSuccess"
),
});
if
(
sessionsResponse
?.
data
&&
sessionsResponse
.
data
.
length
===
1
&&
sessionPage
>
1
)
{
setSessionPage
((
prev
)
=>
prev
-
1
);
}
queryClient
.
invalidateQueries
({
queryKey
:
[
"sessions"
]
});
},
onError
:
(
error
:
unknown
)
=>
{
...
...
@@ -261,6 +316,7 @@ const ProfilePage: React.FC = () => {
type
:
"success"
,
text
:
t
(
"profile.logoutOthersSuccess"
),
});
setSessionPage
(
1
);
queryClient
.
invalidateQueries
({
queryKey
:
[
"sessions"
]
});
},
onError
:
(
error
:
unknown
)
=>
{
...
...
@@ -287,6 +343,7 @@ const ProfilePage: React.FC = () => {
};
const
onProfileSubmit
=
(
values
:
ProfileFormValues
)
=>
{
if
(
!
isProfileChanged
)
return
;
updateProfileMutation
.
mutate
(
values
);
};
...
...
@@ -376,8 +433,8 @@ const ProfilePage: React.FC = () => {
<
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-clay-highlight/50"
>
<
div
className=
"relative h-20 w-20 flex-none"
>
<
Card
className=
"flex items-
start
gap-4 py-4 bg-clay-surface border border-clay-highlight/50"
>
<
div
className=
"relative h-20 w-20 flex-none
self-center sm:self-start
"
>
<
button
type=
"button"
aria
-
label=
{
t
(
"profile.editAvatar"
)
}
...
...
@@ -386,7 +443,7 @@ const ProfilePage: React.FC = () => {
>
<
Avatar
size=
"lg"
src=
{
currentA
vatarUrl
||
""
}
src=
{
user
?.
a
vatarUrl
||
""
}
positionX=
{
user
?.
avatarPositionX
}
positionY=
{
user
?.
avatarPositionY
}
className=
"pointer-events-none border-clay-primary shadow-clay-hover"
...
...
@@ -399,23 +456,53 @@ const ProfilePage: React.FC = () => {
</
svg
>
</
span
>
</
div
>
<
div
className=
"flex-1 min-w-0"
>
<
h2
className=
"clay-title-h2 truncate text-clay-primary"
>
{
user
?.
fullName
||
t
(
"profile.notSet"
)
}
</
h2
>
<
p
className=
"clay-caption truncate"
>
{
user
?.
email
}
</
p
>
<
div
className=
"mt-1 flex gap-2"
>
<
div
className=
"flex-1 min-w-0 space-y-1.5"
>
<
div
>
<
h2
className=
"clay-title-h2 truncate text-clay-primary"
>
{
user
?.
fullName
||
t
(
"profile.notSet"
)
}
</
h2
>
<
p
className=
"clay-caption truncate text-clay-text-muted"
>
{
user
?.
email
||
"—"
}
</
p
>
</
div
>
<
div
className=
"flex flex-wrap items-center gap-1.5"
>
<
Badge
type=
"primary"
>
{
user
?.
role
?.
name
||
"USER"
}
</
Badge
>
{
user
?.
isActive
&&
<
Badge
type=
"income"
>
{
t
(
"common.active"
)
}
</
Badge
>
}
</
div
>
<
div
className=
"flex flex-col gap-1 border-t border-clay-highlight/40 pt-2 text-xs"
>
<
div
className=
"flex items-center gap-1.5 min-w-0"
>
<
span
className=
"font-semibold text-clay-text-muted shrink-0"
>
{
t
(
"profile.userId"
)
}
:
</
span
>
<
span
className=
"font-mono text-[11px] text-clay-text truncate select-all"
title=
{
user
?.
id
}
>
{
user
?.
id
||
"—"
}
</
span
>
{
user
?.
id
&&
(
<
button
type=
"button"
onClick=
{
handleCopyId
}
aria
-
label=
{
t
(
"profile.copyId"
)
}
title=
{
t
(
"profile.copyId"
)
}
className=
"ml-auto inline-flex shrink-0 items-center justify-center rounded-clay-sm p-1 text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary active:scale-95"
>
{
isIdCopied
?
(
<
CheckIcon
size=
{
14
}
className=
"text-clay-income"
/>
)
:
(
<
CopyIcon
size=
{
14
}
/>
)
}
</
button
>
)
}
</
div
>
<
div
className=
"flex items-center gap-1.5"
>
<
span
className=
"font-semibold text-clay-text-muted shrink-0"
>
{
t
(
"profile.createdAt"
)
}
:
</
span
>
<
span
className=
"text-[11px] font-medium text-clay-text"
>
{
user
?.
createdAt
?
formatDate
(
user
.
createdAt
,
{
day
:
"2-digit"
,
month
:
"2-digit"
,
year
:
"numeric"
,
})
:
"—"
}
</
span
>
</
div
>
</
div
>
</
div
>
</
Card
>
<
Card
className=
"flex items-center justify-between gap-4 p-4"
>
<
div
className=
"min-w-0"
>
<
h3
className=
"clay-title-h3"
>
{
t
(
"theme.section"
)
}
</
h3
>
<
p
className=
"clay-caption"
>
{
t
(
theme
===
"dark"
?
"theme.dark"
:
"theme.light"
)
}
·
{
t
(
"theme.saved"
)
}
</
p
>
</
div
>
<
ThemeToggle
theme=
{
theme
}
onChange=
{
setTheme
}
/>
</
Card
>
{
/* Navigation Tabs */
}
<
Tabs
tabs=
{
tabs
}
activeTab=
{
activeTab
}
onChange=
{
setActiveTab
}
/>
...
...
@@ -441,13 +528,11 @@ const ProfilePage: React.FC = () => {
disabled=
{
updateProfileMutation
.
isPending
}
/>
<
input
type=
"hidden"
{
...
registerProfile
("
avatarUrl
")}
/>
<
Button
variant=
"primary"
type=
"submit"
fullWidth
disabled=
{
updateProfileMutation
.
isPending
}
disabled=
{
!
isProfileChanged
||
updateProfileMutation
.
isPending
}
className=
"mt-2"
>
{
updateProfileMutation
.
isPending
?
t
(
"common.saving"
)
:
t
(
"profile.update"
)
}
...
...
@@ -526,11 +611,18 @@ const ProfilePage: React.FC = () => {
{
/* List of active sessions */
}
<
Card
>
<
h3
className=
"clay-title-h3 text-clay-primary-dark mb-4 border-b pb-2"
>
{
t
(
"profile.activeDevices"
)
}
</
h3
>
<
div
className=
"flex items-center justify-between mb-4 border-b pb-2"
>
<
h3
className=
"clay-title-h3 text-clay-primary-dark"
>
{
t
(
"profile.activeDevices"
)
}
</
h3
>
{
sessionsResponse
?.
meta
&&
(
<
span
className=
"clay-caption text-xs"
>
{
t
(
"common.results"
,
{
count
:
sessionsResponse
.
meta
.
total
})
}
</
span
>
)
}
</
div
>
{
sessionsLoading
&&
(
<
div
className=
"flex flex-col gap-3"
aria
-
label=
{
t
(
"profile.sessionsLoading"
)
}
>
{
[
1
,
2
].
map
((
i
)
=>
(
{
[
1
,
2
,
3
].
map
((
i
)
=>
(
<
div
key=
{
i
}
className=
"animate-pulse bg-clay-bg h-16 w-full rounded-clay-sm"
></
div
>
))
}
</
div
>
...
...
@@ -583,6 +675,30 @@ const ProfilePage: React.FC = () => {
{
sessionsResponse
.
data
.
length
===
0
&&
(
<
p
className=
"text-center clay-caption py-4"
>
{
t
(
"profile.noSessions"
)
}
</
p
>
)
}
{
sessionsResponse
.
meta
&&
sessionsResponse
.
meta
.
totalPages
>
1
&&
(
<
nav
className=
"flex items-center justify-between gap-3 pt-3 border-t border-clay-text/5 mt-1"
aria
-
label=
{
t
(
"profile.paginationLabel"
)
}
>
<
Button
variant=
"secondary"
className=
"px-3 py-1.5 text-xs font-semibold"
disabled=
{
sessionPage
<=
1
||
sessionsFetching
}
onClick=
{
()
=>
setSessionPage
((
prev
)
=>
Math
.
max
(
1
,
prev
-
1
))
}
>
{
t
(
"common.previous"
)
}
</
Button
>
<
span
className=
"font-nunito text-xs font-bold text-clay-text"
>
{
t
(
"common.pageOf"
,
{
page
:
sessionPage
,
total
:
sessionsResponse
.
meta
.
totalPages
})
}
</
span
>
<
Button
variant=
"secondary"
className=
"px-3 py-1.5 text-xs font-semibold"
disabled=
{
sessionPage
>=
sessionsResponse
.
meta
.
totalPages
||
sessionsFetching
}
onClick=
{
()
=>
setSessionPage
((
prev
)
=>
prev
+
1
)
}
>
{
t
(
"common.next"
)
}
</
Button
>
</
nav
>
)
}
</
div
>
)
}
</
Card
>
...
...
src/services/auth.service.ts
View file @
7f6b0ec4
import
{
apiClient
}
from
"@/lib/api-client"
;
import
{
ApiResponse
,
LoginRequest
,
User
,
Session
,
UpdateProfileRequest
}
from
"@/types/auth"
;
import
{
ApiResponse
,
LoginRequest
,
User
,
Session
,
UpdateProfileRequest
,
UpdateAvatarRequest
,
SessionQuery
,
SessionsResponse
}
from
"@/types/auth"
;
export
const
authService
=
{
async
register
(
data
:
any
):
Promise
<
ApiResponse
>
{
...
...
@@ -32,6 +32,16 @@ export const authService = {
return
response
.
data
;
},
async
updateAvatar
(
data
:
UpdateAvatarRequest
):
Promise
<
ApiResponse
<
User
>>
{
const
response
=
await
apiClient
.
put
(
"/auth/avatar"
,
data
);
return
response
.
data
;
},
async
deleteAvatar
():
Promise
<
ApiResponse
<
User
>>
{
const
response
=
await
apiClient
.
delete
(
"/auth/avatar"
);
return
response
.
data
;
},
async
updatePassword
(
data
:
any
):
Promise
<
ApiResponse
>
{
const
response
=
await
apiClient
.
put
(
"/auth/password"
,
data
);
return
response
.
data
;
...
...
@@ -47,8 +57,8 @@ export const authService = {
return
response
.
data
;
},
async
getSessions
(
):
Promise
<
ApiResponse
<
Session
[]
>
>
{
const
response
=
await
apiClient
.
get
(
"/auth/sessions"
);
async
getSessions
(
params
?:
SessionQuery
):
Promise
<
SessionsResponse
>
{
const
response
=
await
apiClient
.
get
(
"/auth/sessions"
,
{
params
}
);
return
response
.
data
;
},
...
...
src/services/upload.service.ts
View file @
7f6b0ec4
...
...
@@ -34,13 +34,18 @@ export const uploadService = {
}
const
upload
=
presignResponse
.
data
;
const
response
=
await
fetch
(
upload
.
uploadUrl
,
{
method
:
"PUT"
,
headers
:
upload
.
requiredHeaders
,
body
:
file
,
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Tải ảnh lên thất bại (
${
response
.
status
}
).`
);
try
{
const
response
=
await
fetch
(
upload
.
uploadUrl
,
{
method
:
"PUT"
,
headers
:
upload
.
requiredHeaders
,
body
:
file
,
});
if
(
!
response
.
ok
)
{
throw
new
Error
(
`Tải ảnh lên dịch vụ lưu trữ thất bại (HTTP
${
response
.
status
}
:
${
response
.
statusText
||
"Lỗi lưu trữ"
}
).`
);
}
}
catch
(
err
:
unknown
)
{
if
(
err
instanceof
Error
)
throw
err
;
throw
new
Error
(
`Lỗi kết nối khi tải ảnh lên:
${
String
(
err
)}
`
);
}
return
upload
;
...
...
src/types/auth.ts
View file @
7f6b0ec4
...
...
@@ -88,10 +88,20 @@ export interface LoginRequest {
export
interface
UpdateProfileRequest
{
fullName
?:
string
;
avatarUrl
?:
string
|
null
;
phoneNumber
?:
string
;
}
export
interface
UpdateAvatarRequest
{
avatarUrl
:
string
|
null
;
avatarPositionX
?:
number
;
avatarPositionY
?:
number
;
phoneNumber
?:
string
;
}
import
{
PaginationMeta
}
from
"./wallet"
;
export
interface
SessionQuery
{
page
?:
number
;
limit
?:
number
;
}
export
interface
Session
{
...
...
@@ -102,9 +112,14 @@ export interface Session {
isCurrent
:
boolean
;
}
export
interface
SessionsResponse
extends
ApiResponse
<
Session
[]
>
{
meta
:
PaginationMeta
;
}
export
interface
ApiResponse
<
T
=
any
>
{
success
:
boolean
;
message
:
string
;
data
:
T
;
errors
?:
any
[]
|
null
;
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment