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 }) => (
</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 @@
"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!",
......
......@@ -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.",
......
/**
* 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 {
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;
}
......
......@@ -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...'
......
......@@ -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 (
......
This diff is collapsed.
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;
},
......
......@@ -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;
......
......@@ -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;
}
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