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
Expand all
Show 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
This diff is collapsed.
Click to expand it.
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
;
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 thất bại (
${
response
.
status
}
).`
);
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