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 (
......
import React, { useMemo, useState } from "react";
import React, { useEffect, useMemo, 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={currentAvatarUrl || ""}
src={user?.avatarUrl || ""}
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>
......
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;
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;
......
......@@ -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