Commit 2c4ec0b4 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/profile-enhancements-and-session-pagination' into 'develop'

feat(profile): enhance user profile UI, add session pagination, and optimize dirty checks

See merge request !30
parents 28aefb92 7f6b0ec4
...@@ -307,4 +307,13 @@ export const SendIcon: React.FC<IconProps> = ({ size = 20, ...props }) => ( ...@@ -307,4 +307,13 @@ export const SendIcon: React.FC<IconProps> = ({ size = 20, ...props }) => (
</svg> </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>
);
...@@ -158,7 +158,7 @@ ...@@ -158,7 +158,7 @@
"submit": "Sign In", "submit": "Sign In",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"registerNow": "Sign up now", "registerNow": "Sign up now",
"success": "Signed in successfully! 🎉", "success": "Signed in successfully!",
"failed": "Sign-in failed", "failed": "Sign-in failed",
"failedDetail": "Sign-in failed. Please check your details and try again." "failedDetail": "Sign-in failed. Please check your details and try again."
}, },
...@@ -190,7 +190,7 @@ ...@@ -190,7 +190,7 @@
"submit": "Send Request", "submit": "Send Request",
"rememberPassword": "Remember your password?", "rememberPassword": "Remember your password?",
"login": "Sign in", "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", "failed": "Could not send request",
"failedDetail": "Could not send the request. Please try again.", "failedDetail": "Could not send the request. Please try again.",
"sentTitle": "Email Sent", "sentTitle": "Email Sent",
...@@ -244,6 +244,11 @@ ...@@ -244,6 +244,11 @@
"header": "Account", "header": "Account",
"editAvatar": "Edit profile picture", "editAvatar": "Edit profile picture",
"notSet": "Not set", "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" }, "tabs": { "info": "Profile", "password": "Password", "devices": "Devices" },
"personalInfo": "Personal information", "personalInfo": "Personal information",
"fullName": "Full name", "fullName": "Full name",
...@@ -261,14 +266,14 @@ ...@@ -261,14 +266,14 @@
"confirmNewPassword": "Confirm new password", "confirmNewPassword": "Confirm new password",
"confirmNewPasswordPlaceholder": "Enter the new password again...", "confirmNewPasswordPlaceholder": "Enter the new password again...",
"passwordSubmit": "Change Password", "passwordSubmit": "Change Password",
"passwordSuccess": "Password changed successfully! 🔑", "passwordSuccess": "Password changed successfully!",
"passwordFailed": "Could not change password.", "passwordFailed": "Could not change password.",
"showPassword": "Show password", "showPassword": "Show password",
"hidePassword": "Hide password", "hidePassword": "Hide password",
"sessionManagement": "Manage sign-in sessions", "sessionManagement": "Manage sign-in sessions",
"sessionDescription": "Protect your account by signing out of all other devices and browsers you previously used.", "sessionDescription": "Protect your account by signing out of all other devices and browsers you previously used.",
"logoutOthers": "Sign out other devices", "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.", "logoutOthersFailed": "Could not sign out other devices.",
"activeDevices": "Active devices", "activeDevices": "Active devices",
"sessionsLoading": "Loading session list", "sessionsLoading": "Loading session list",
...@@ -279,10 +284,11 @@ ...@@ -279,10 +284,11 @@
"ipAddress": "IP: {{address}}", "ipAddress": "IP: {{address}}",
"logout": "Sign Out", "logout": "Sign Out",
"logoutDevice": "Sign out", "logoutDevice": "Sign out",
"logoutSuccess": "Signed out. See you next time! 👋", "logoutSuccess": "Signed out. See you next time!",
"revokeSuccess": "Session closed successfully.", "revokeSuccess": "Session closed successfully.",
"revokeFailed": "Could not close the session.", "revokeFailed": "Could not close the session.",
"noSessions": "There are no other sign-in sessions.", "noSessions": "There are no other sign-in sessions.",
"paginationLabel": "Device pagination",
"avatarSelectRequired": "Choose a profile picture before saving.", "avatarSelectRequired": "Choose a profile picture before saving.",
"avatarSaveFailed": "Could not save the profile picture to your profile.", "avatarSaveFailed": "Could not save the profile picture to your profile.",
"avatarSaveSuccess": "Picture and crop position saved!", "avatarSaveSuccess": "Picture and crop position saved!",
......
...@@ -244,6 +244,11 @@ ...@@ -244,6 +244,11 @@
"header": "Tài Khoản", "header": "Tài Khoản",
"editAvatar": "Chỉnh ảnh đại diện", "editAvatar": "Chỉnh ảnh đại diện",
"notSet": "Chưa thiết lập", "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": { "tabs": {
"info": "Thông Tin", "info": "Thông Tin",
"password": "Mật Khẩu", "password": "Mật Khẩu",
...@@ -255,7 +260,7 @@ ...@@ -255,7 +260,7 @@
"phone": "Số điện thoại", "phone": "Số điện thoại",
"phonePlaceholder": "Nhập số điện thoại...", "phonePlaceholder": "Nhập số điện thoại...",
"update": "Cập Nhật Thông Tin", "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.", "updateFailed": "Cập nhật thất bại, vui lòng thử lại.",
"changePassword": "Đổi mật khẩu", "changePassword": "Đổi mật khẩu",
"currentPassword": "Mật khẩu hiện tại", "currentPassword": "Mật khẩu hiện tại",
...@@ -265,14 +270,14 @@ ...@@ -265,14 +270,14 @@
"confirmNewPassword": "Xác nhận mật khẩu mới", "confirmNewPassword": "Xác nhận mật khẩu mới",
"confirmNewPasswordPlaceholder": "Nhập lại mật khẩu mới...", "confirmNewPasswordPlaceholder": "Nhập lại mật khẩu mới...",
"passwordSubmit": "Đổi Mật Khẩu", "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.", "passwordFailed": "Thay đổi mật khẩu thất bại.",
"showPassword": "Hiện mật khẩu", "showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu", "hidePassword": "Ẩn mật khẩu",
"sessionManagement": "Quản lý phiên đăng nhập", "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 đó.", "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", "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.", "logoutOthersFailed": "Không thể đăng xuất các thiết bị khác.",
"activeDevices": "Thiết bị đang hoạt động", "activeDevices": "Thiết bị đang hoạt động",
"sessionsLoading": "Đang tải danh sách phiên", "sessionsLoading": "Đang tải danh sách phiên",
...@@ -283,10 +288,11 @@ ...@@ -283,10 +288,11 @@
"ipAddress": "IP: {{address}}", "ipAddress": "IP: {{address}}",
"logout": "Đăng Xuất", "logout": "Đăng Xuất",
"logoutDevice": "Đă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.", "revokeSuccess": "Đã đóng phiên đăng nhập thành công.",
"revokeFailed": "Không thể đóng phiên đăng nhập.", "revokeFailed": "Không thể đóng phiên đăng nhập.",
"noSessions": "Không có phiên đăng nhập nào khác.", "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.", "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ơ.", "avatarSaveFailed": "Không thể lưu ảnh đại diện vào hồ sơ.",
"avatarSaveSuccess": "Đã lưu ảnh và vùng hiển thị!", "avatarSaveSuccess": "Đã lưu ảnh và vùng hiển thị!",
...@@ -971,9 +977,9 @@ ...@@ -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.", "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...", "loading": "Đang tải danh sách giao dịch...",
"loadFailed": "Tải giao dịch thất bại", "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.", "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.", "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!", "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.", "deleteFailed": "Không thể xóa giao dịch. Vui lòng thử lại.",
......
/**
* 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;
}
}
...@@ -8,10 +8,13 @@ export function getErrorMessage(error: unknown, fallback: string): string { ...@@ -8,10 +8,13 @@ export function getErrorMessage(error: unknown, fallback: string): string {
if (typeof serverMessage === "string" && serverMessage.trim().length > 0) { if (typeof serverMessage === "string" && serverMessage.trim().length > 0) {
return serverMessage; return serverMessage;
} }
if (error.message && error.message.trim().length > 0) {
return error.message;
}
return fallback; return fallback;
} }
if (error instanceof LocalizedError && error.message) { if (error instanceof Error && error.message && error.message.trim().length > 0) {
return error.message; return error.message;
} }
......
...@@ -168,6 +168,28 @@ const RolesPage: React.FC = () => { ...@@ -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 // Handlers
const handleOpenMatrix = (role: Role) => { const handleOpenMatrix = (role: Role) => {
setSelectedRole(role); setSelectedRole(role);
...@@ -206,6 +228,10 @@ const RolesPage: React.FC = () => { ...@@ -206,6 +228,10 @@ const RolesPage: React.FC = () => {
const handleSaveMatrix = () => { const handleSaveMatrix = () => {
if (!selectedRole) return; if (!selectedRole) return;
if (!isMatrixDirty) {
setIsMatrixModalOpen(false);
return;
}
assignPermissionsMutation.mutate({ assignPermissionsMutation.mutate({
roleId: selectedRole.id, roleId: selectedRole.id,
permissionIds: selectedPermissionIds, permissionIds: selectedPermissionIds,
...@@ -224,6 +250,10 @@ const RolesPage: React.FC = () => { ...@@ -224,6 +250,10 @@ const RolesPage: React.FC = () => {
const handleUpdateRole = (e: React.FormEvent) => { const handleUpdateRole = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!selectedRole) return; if (!selectedRole) return;
if (!isEditRoleDirty) {
setIsEditModalOpen(false);
return;
}
updateRoleMutation.mutate({ updateRoleMutation.mutate({
id: selectedRole.id, id: selectedRole.id,
data: { data: {
...@@ -417,7 +447,7 @@ const RolesPage: React.FC = () => { ...@@ -417,7 +447,7 @@ const RolesPage: React.FC = () => {
<Button <Button
variant="primary" variant="primary"
onClick={handleSaveMatrix} onClick={handleSaveMatrix}
disabled={assignPermissionsMutation.isPending} disabled={!isMatrixDirty || assignPermissionsMutation.isPending}
> >
{assignPermissionsMutation.isPending {assignPermissionsMutation.isPending
? t('common.saving') || 'Đang lưu...' ? t('common.saving') || 'Đang lưu...'
...@@ -578,7 +608,7 @@ const RolesPage: React.FC = () => { ...@@ -578,7 +608,7 @@ const RolesPage: React.FC = () => {
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
disabled={updateRoleMutation.isPending} disabled={!isEditRoleDirty || updateRoleMutation.isPending}
> >
{updateRoleMutation.isPending {updateRoleMutation.isPending
? t('common.saving') || 'Đang lưu...' ? t('common.saving') || 'Đang lưu...'
......
...@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/Button"; ...@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { CheckIcon, KeyIcon } from "@/components/ui/icons"; import { CheckIcon, KeyIcon } from "@/components/ui/icons";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { copyToClipboard } from "@/lib/clipboard";
interface OneTimeSecretModalProps { interface OneTimeSecretModalProps {
isOpen: boolean; isOpen: boolean;
...@@ -23,12 +24,8 @@ export const OneTimeSecretModal: React.FC<OneTimeSecretModalProps> = ({ ...@@ -23,12 +24,8 @@ export const OneTimeSecretModal: React.FC<OneTimeSecretModalProps> = ({
useEffect(() => setCopied(false), [isOpen, secret]); useEffect(() => setCopied(false), [isOpen, secret]);
const copySecret = async () => { const copySecret = async () => {
try { const success = await copyToClipboard(secret);
await navigator.clipboard.writeText(secret); setCopied(success);
setCopied(true);
} catch {
setCopied(false);
}
}; };
return ( return (
......
This diff is collapsed.
import { apiClient } from "@/lib/api-client"; 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 = { export const authService = {
async register(data: any): Promise<ApiResponse> { async register(data: any): Promise<ApiResponse> {
...@@ -32,6 +32,16 @@ export const authService = { ...@@ -32,6 +32,16 @@ export const authService = {
return response.data; 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> { async updatePassword(data: any): Promise<ApiResponse> {
const response = await apiClient.put("/auth/password", data); const response = await apiClient.put("/auth/password", data);
return response.data; return response.data;
...@@ -47,8 +57,8 @@ export const authService = { ...@@ -47,8 +57,8 @@ export const authService = {
return response.data; return response.data;
}, },
async getSessions(): Promise<ApiResponse<Session[]>> { async getSessions(params?: SessionQuery): Promise<SessionsResponse> {
const response = await apiClient.get("/auth/sessions"); const response = await apiClient.get("/auth/sessions", { params });
return response.data; return response.data;
}, },
......
...@@ -34,13 +34,18 @@ export const uploadService = { ...@@ -34,13 +34,18 @@ export const uploadService = {
} }
const upload = presignResponse.data; const upload = presignResponse.data;
try {
const response = await fetch(upload.uploadUrl, { const response = await fetch(upload.uploadUrl, {
method: "PUT", method: "PUT",
headers: upload.requiredHeaders, headers: upload.requiredHeaders,
body: file, body: file,
}); });
if (!response.ok) { 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; return upload;
......
...@@ -88,10 +88,20 @@ export interface LoginRequest { ...@@ -88,10 +88,20 @@ export interface LoginRequest {
export interface UpdateProfileRequest { export interface UpdateProfileRequest {
fullName?: string; fullName?: string;
avatarUrl?: string | null; phoneNumber?: string;
}
export interface UpdateAvatarRequest {
avatarUrl: string | null;
avatarPositionX?: number; avatarPositionX?: number;
avatarPositionY?: number; avatarPositionY?: number;
phoneNumber?: string; }
import { PaginationMeta } from "./wallet";
export interface SessionQuery {
page?: number;
limit?: number;
} }
export interface Session { export interface Session {
...@@ -102,9 +112,14 @@ export interface Session { ...@@ -102,9 +112,14 @@ export interface Session {
isCurrent: boolean; isCurrent: boolean;
} }
export interface SessionsResponse extends ApiResponse<Session[]> {
meta: PaginationMeta;
}
export interface ApiResponse<T = any> { export interface ApiResponse<T = any> {
success: boolean; success: boolean;
message: string; message: string;
data: T; data: T;
errors?: any[] | null; 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