Commit 7f6b0ec4 authored by ThinhNC's avatar ThinhNC

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

parent 4d439196
...@@ -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 (
......
import React, { useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { Page, Header, useNavigate, useSnackbar } from "zmp-ui"; import { Page, Header, useNavigate, useSnackbar } from "zmp-ui";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
...@@ -11,17 +11,17 @@ import { Input } from "@/components/ui/Input"; ...@@ -11,17 +11,17 @@ import { Input } from "@/components/ui/Input";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { Tabs } from "@/components/ui/Tabs"; import { Tabs } from "@/components/ui/Tabs";
import { Badge } from "@/components/ui/Badge"; import { Badge } from "@/components/ui/Badge";
import { ThemeToggle } from "@/components/ui/ThemeToggle";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useThemeStore } from "@/stores/theme-store";
import { authService } from "@/services/auth.service"; 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 { import {
AvatarEditorModal, AvatarEditorModal,
AvatarPosition, AvatarPosition,
} from "@/pages/profile/avatar-editor-modal"; } from "@/pages/profile/avatar-editor-modal";
import { TranslationFunction, useI18n } from "@/i18n"; import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage, LocalizedError } from "@/lib/error-message"; import { getErrorMessage, LocalizedError } from "@/lib/error-message";
import { copyToClipboard } from "@/lib/clipboard";
const AVATAR_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; const AVATAR_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
const AVATAR_ACCEPT = "image/jpeg,image/png,image/webp"; const AVATAR_ACCEPT = "image/jpeg,image/png,image/webp";
...@@ -33,7 +33,6 @@ const createProfileSchema = (t: TranslationFunction) => z.object({ ...@@ -33,7 +33,6 @@ const createProfileSchema = (t: TranslationFunction) => z.object({
.string() .string()
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, t("validation.phoneInvalid")) .regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, t("validation.phoneInvalid"))
.or(z.literal("")), .or(z.literal("")),
avatarUrl: z.string().url(t("validation.avatarUrlInvalid")).or(z.literal("")),
}); });
const createPasswordSchema = (t: TranslationFunction) => z const createPasswordSchema = (t: TranslationFunction) => z
...@@ -61,7 +60,6 @@ const ProfilePage: React.FC = () => { ...@@ -61,7 +60,6 @@ const ProfilePage: React.FC = () => {
const { openSnackbar } = useSnackbar(); const { openSnackbar } = useSnackbar();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user, setUser, clearAuth } = useAuthStore(); const { user, setUser, clearAuth } = useAuthStore();
const { theme, setTheme } = useThemeStore();
const { formatDate, t } = useI18n(); const { formatDate, t } = useI18n();
const profileSchema = useMemo(() => createProfileSchema(t), [t]); const profileSchema = useMemo(() => createProfileSchema(t), [t]);
const passwordSchema = useMemo(() => createPasswordSchema(t), [t]); const passwordSchema = useMemo(() => createPasswordSchema(t), [t]);
...@@ -80,6 +78,7 @@ const ProfilePage: React.FC = () => { ...@@ -80,6 +78,7 @@ const ProfilePage: React.FC = () => {
newPassword: false, newPassword: false,
confirmPassword: false, confirmPassword: false,
}); });
const [isIdCopied, setIsIdCopied] = useState(false);
const tabs = [ const tabs = [
{ key: "profile", label: t("profile.tabs.info") }, { key: "profile", label: t("profile.tabs.info") },
...@@ -87,24 +86,62 @@ const ProfilePage: React.FC = () => { ...@@ -87,24 +86,62 @@ const ProfilePage: React.FC = () => {
{ key: "sessions", label: t("profile.tabs.devices") }, { 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 // Forms setup
const { const {
register: registerProfile, register: registerProfile,
handleSubmit: handleProfileSubmit, handleSubmit: handleProfileSubmit,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
reset: resetProfileForm, reset: resetProfileForm,
setValue: setProfileValue,
watch: watchProfile, watch: watchProfile,
} = useForm<ProfileFormValues>({ } = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
defaultValues: { defaultValues: {
fullName: user?.fullName || "", fullName: user?.fullName || "",
phoneNumber: user?.phoneNumber || "", 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 { const {
register: registerPassword, register: registerPassword,
...@@ -115,10 +152,17 @@ const ProfilePage: React.FC = () => { ...@@ -115,10 +152,17 @@ const ProfilePage: React.FC = () => {
resolver: zodResolver(passwordSchema), resolver: zodResolver(passwordSchema),
}); });
const [sessionPage, setSessionPage] = useState(1);
// React Query for active sessions list // React Query for active sessions list
const { data: sessionsResponse, isLoading: sessionsLoading, error: sessionsError } = useQuery({ const {
queryKey: ["sessions"], data: sessionsResponse,
queryFn: authService.getSessions, isLoading: sessionsLoading,
isFetching: sessionsFetching,
error: sessionsError,
} = useQuery({
queryKey: ["sessions", sessionPage],
queryFn: () => authService.getSessions({ page: sessionPage, limit: 5 }),
enabled: activeTab === "sessions", enabled: activeTab === "sessions",
}); });
...@@ -135,7 +179,6 @@ const ProfilePage: React.FC = () => { ...@@ -135,7 +179,6 @@ const ProfilePage: React.FC = () => {
resetProfileForm({ resetProfileForm({
fullName: response.data.fullName || "", fullName: response.data.fullName || "",
phoneNumber: response.data.phoneNumber || "", phoneNumber: response.data.phoneNumber || "",
avatarUrl: response.data.avatarUrl || "",
}); });
} }
}, },
...@@ -149,7 +192,7 @@ const ProfilePage: React.FC = () => { ...@@ -149,7 +192,7 @@ const ProfilePage: React.FC = () => {
const saveAvatarMutation = useMutation({ const saveAvatarMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
let avatarUrl = currentAvatarUrl || user?.avatarUrl || null; let avatarUrl = user?.avatarUrl || null;
if (avatarFile) { if (avatarFile) {
const upload = await uploadService.uploadAvatar(avatarFile); const upload = await uploadService.uploadAvatar(avatarFile);
avatarUrl = upload.publicUrl; avatarUrl = upload.publicUrl;
...@@ -158,7 +201,17 @@ const ProfilePage: React.FC = () => { ...@@ -158,7 +201,17 @@ const ProfilePage: React.FC = () => {
throw new LocalizedError(t("profile.avatarSelectRequired")); 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, avatarUrl,
avatarPositionX: avatarPosition.x, avatarPositionX: avatarPosition.x,
avatarPositionY: avatarPosition.y, avatarPositionY: avatarPosition.y,
...@@ -169,7 +222,6 @@ const ProfilePage: React.FC = () => { ...@@ -169,7 +222,6 @@ const ProfilePage: React.FC = () => {
return response.data; return response.data;
}, },
onSuccess: (updatedUser) => { onSuccess: (updatedUser) => {
setProfileValue("avatarUrl", updatedUser.avatarUrl || "", { shouldDirty: false });
setUser(updatedUser); setUser(updatedUser);
setAvatarFile(null); setAvatarFile(null);
setAvatarPreviewUrl(null); setAvatarPreviewUrl(null);
...@@ -180,6 +232,7 @@ const ProfilePage: React.FC = () => { ...@@ -180,6 +232,7 @@ const ProfilePage: React.FC = () => {
}); });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
console.error("Save avatar error:", error);
openSnackbar({ openSnackbar({
type: "error", type: "error",
text: getErrorMessage(error, t("profile.avatarUploadFailed")), text: getErrorMessage(error, t("profile.avatarUploadFailed")),
...@@ -189,14 +242,13 @@ const ProfilePage: React.FC = () => { ...@@ -189,14 +242,13 @@ const ProfilePage: React.FC = () => {
const deleteAvatarMutation = useMutation({ const deleteAvatarMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const response = await authService.updateProfile({ avatarUrl: null }); const response = await authService.deleteAvatar();
if (!response.success || !response.data) { if (!response.success || !response.data) {
throw new LocalizedError(t("profile.avatarDeleteFailed")); throw new LocalizedError(t("profile.avatarDeleteFailed"));
} }
return response.data; return response.data;
}, },
onSuccess: (updatedUser) => { onSuccess: (updatedUser) => {
setProfileValue("avatarUrl", "", { shouldDirty: false });
setUser(updatedUser); setUser(updatedUser);
setAvatarFile(null); setAvatarFile(null);
setAvatarPreviewUrl(null); setAvatarPreviewUrl(null);
...@@ -244,6 +296,9 @@ const ProfilePage: React.FC = () => { ...@@ -244,6 +296,9 @@ const ProfilePage: React.FC = () => {
type: "success", type: "success",
text: t("profile.revokeSuccess"), text: t("profile.revokeSuccess"),
}); });
if (sessionsResponse?.data && sessionsResponse.data.length === 1 && sessionPage > 1) {
setSessionPage((prev) => prev - 1);
}
queryClient.invalidateQueries({ queryKey: ["sessions"] }); queryClient.invalidateQueries({ queryKey: ["sessions"] });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
...@@ -261,6 +316,7 @@ const ProfilePage: React.FC = () => { ...@@ -261,6 +316,7 @@ const ProfilePage: React.FC = () => {
type: "success", type: "success",
text: t("profile.logoutOthersSuccess"), text: t("profile.logoutOthersSuccess"),
}); });
setSessionPage(1);
queryClient.invalidateQueries({ queryKey: ["sessions"] }); queryClient.invalidateQueries({ queryKey: ["sessions"] });
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
...@@ -287,6 +343,7 @@ const ProfilePage: React.FC = () => { ...@@ -287,6 +343,7 @@ const ProfilePage: React.FC = () => {
}; };
const onProfileSubmit = (values: ProfileFormValues) => { const onProfileSubmit = (values: ProfileFormValues) => {
if (!isProfileChanged) return;
updateProfileMutation.mutate(values); updateProfileMutation.mutate(values);
}; };
...@@ -376,8 +433,8 @@ const ProfilePage: React.FC = () => { ...@@ -376,8 +433,8 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col gap-6 mt-4 pb-20"> <div className="flex flex-col gap-6 mt-4 pb-20">
{/* User Card Header */} {/* User Card Header */}
<Card className="flex items-center gap-4 py-4 bg-clay-surface border border-clay-highlight/50"> <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"> <div className="relative h-20 w-20 flex-none self-center sm:self-start">
<button <button
type="button" type="button"
aria-label={t("profile.editAvatar")} aria-label={t("profile.editAvatar")}
...@@ -386,7 +443,7 @@ const ProfilePage: React.FC = () => { ...@@ -386,7 +443,7 @@ const ProfilePage: React.FC = () => {
> >
<Avatar <Avatar
size="lg" size="lg"
src={currentAvatarUrl || ""} src={user?.avatarUrl || ""}
positionX={user?.avatarPositionX} positionX={user?.avatarPositionX}
positionY={user?.avatarPositionY} positionY={user?.avatarPositionY}
className="pointer-events-none border-clay-primary shadow-clay-hover" className="pointer-events-none border-clay-primary shadow-clay-hover"
...@@ -399,24 +456,54 @@ const ProfilePage: React.FC = () => { ...@@ -399,24 +456,54 @@ const ProfilePage: React.FC = () => {
</svg> </svg>
</span> </span>
</div> </div>
<div className="flex-1 min-w-0"> <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> <h2 className="clay-title-h2 truncate text-clay-primary">{user?.fullName || t("profile.notSet")}</h2>
<p className="clay-caption truncate">{user?.email}</p> <p className="clay-caption truncate text-clay-text-muted">{user?.email || "—"}</p>
<div className="mt-1 flex gap-2"> </div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge type="primary">{user?.role?.name || "USER"}</Badge> <Badge type="primary">{user?.role?.name || "USER"}</Badge>
{user?.isActive && <Badge type="income">{t("common.active")}</Badge>} {user?.isActive && <Badge type="income">{t("common.active")}</Badge>}
</div> </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> </div>
<ThemeToggle theme={theme} onChange={setTheme} />
</Card> </Card>
{/* Navigation Tabs */} {/* Navigation Tabs */}
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} /> <Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
...@@ -441,13 +528,11 @@ const ProfilePage: React.FC = () => { ...@@ -441,13 +528,11 @@ const ProfilePage: React.FC = () => {
disabled={updateProfileMutation.isPending} disabled={updateProfileMutation.isPending}
/> />
<input type="hidden" {...registerProfile("avatarUrl")} />
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
fullWidth fullWidth
disabled={updateProfileMutation.isPending} disabled={!isProfileChanged || updateProfileMutation.isPending}
className="mt-2" className="mt-2"
> >
{updateProfileMutation.isPending ? t("common.saving") : t("profile.update")} {updateProfileMutation.isPending ? t("common.saving") : t("profile.update")}
...@@ -526,11 +611,18 @@ const ProfilePage: React.FC = () => { ...@@ -526,11 +611,18 @@ const ProfilePage: React.FC = () => {
{/* List of active sessions */} {/* List of active sessions */}
<Card> <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 && ( {sessionsLoading && (
<div className="flex flex-col gap-3" aria-label={t("profile.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 key={i} className="animate-pulse bg-clay-bg h-16 w-full rounded-clay-sm"></div>
))} ))}
</div> </div>
...@@ -583,6 +675,30 @@ const ProfilePage: React.FC = () => { ...@@ -583,6 +675,30 @@ const ProfilePage: React.FC = () => {
{sessionsResponse.data.length === 0 && ( {sessionsResponse.data.length === 0 && (
<p className="text-center clay-caption py-4">{t("profile.noSessions")}</p> <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> </div>
)} )}
</Card> </Card>
......
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