Commit 0e0ca470 authored by ThinhNC's avatar ThinhNC

Merge branch 'feat/auth-ui-enhancements-admin-audit-cleanup' into 'develop'

feat(auth,admin): enhance auth UI and integrate audit log archive cleanup

See merge request !48
parents 10d44cad 43bf8bad
......@@ -90,7 +90,7 @@ File này lưu trữ các quyết định thiết kế dài hạn và trạng th
- QUY TẮC BẮT BUỘC: Tất cả tên quyền (Permission names) PHẢI được import từ `@/common/constants/permission.constant.ts` (hoặc `@/common/constants`), TUYỆT ĐỐI KHÔNG hardcode chuỗi string permission rải rác trong code.
- Các vai trò hệ thống mặc định/bất biến được định nghĩa tập trung qua `SYSTEM_ROLES` trong `src/common/constants/system-role.constant.ts`.
- Màn hình quản lý phân quyền Claymorphism tại `/roles``/admin/roles` gồm: Danh sách Roles, Ma trận phân quyền theo Resource/Action, Thêm/Sửa/Xóa vai trò tùy chỉnh và Nhật ký kiểm toán (Audit Logs).
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục và lịch sử kiểm toán, cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Admin Control Center tại `/admin` dùng TanStack Query và permission động, gồm dashboard KPI/hoạt động gần đây, danh sách người dùng có tìm kiếm-lọc-phân trang, tạo/đổi trạng thái/xóa mềm, chi tiết người dùng có đổi vai trò/khôi phục (lịch sử kiểm toán được quản lý tập trung tại `/admin/audit-logs`), cùng audit viewer lọc theo khoảng ngày Việt Nam/action/target và so sánh JSON trước-sau. Trang chủ chỉ hiện lối vào khi có `USER_READ`.
- Mọi lối vào module trên trang chủ phải được ẩn khi người dùng thiếu quyền đang bảo vệ route tương ứng; tiêu đề nhóm Tính năng Nâng cao cũng ẩn khi không có bất kỳ module con nào khả dụng. Lối vào Hồ sơ vẫn hiển thị vì route này không có permission riêng; lối vào Style Guide mặc định ẩn với vai trò hệ thống `USER` và hiển thị cho các vai trò còn lại.
- Contract Backend hiện chưa cho list hoặc đọc user đã soft-delete (`GET /users``GET /users/:id` đều loại `deletedAt != null`), nên nút restore trên detail đã sẵn sàng theo type/permission nhưng chỉ khả dụng khi Backend trả được bản ghi đã xóa.
......
......@@ -102,3 +102,11 @@ export function useRestoreAdminUser() {
onSuccess: (_response, id) => invalidateAdminData(id),
});
}
export function useArchiveCleanupAuditLogs() {
const invalidateAdminData = useInvalidateAdminData();
return useMutation({
mutationFn: (retentionDays?: number) => rbacService.archiveAndCleanupAuditLogs(retentionDays),
onSuccess: () => invalidateAdminData(),
});
}
import { useState } from "react";
import { authorize, getAccessToken, getPhoneNumber, getUserInfo, getUserID } from "zmp-sdk";
import { useNavigate, useSnackbar } from "zmp-ui";
import { authService } from "@/services/auth.service";
import { useAuthStore } from "@/stores/auth-store";
import { getErrorMessage } from "@/lib/error-message";
import { useI18n } from "@/i18n";
interface UseZaloLoginReturn {
handleZaloLogin: () => Promise<void>;
isLoading: boolean;
}
export function useZaloLogin(): UseZaloLoginReturn {
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const { t } = useI18n();
const handleZaloLogin = async () => {
setIsLoading(true);
try {
// 1. Lấy Zalo access token (string trực tiếp)
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error(t("auth.zalo.tokenFailed"));
}
// 2. Lấy ID và thông tin người dùng từ client SDK
let zaloId: string | undefined;
let name: string | undefined;
let avatar: string | undefined;
try {
const id = await getUserID();
if (id) zaloId = id;
} catch (idErr) {
console.warn("getUserID error:", idErr);
}
try {
await authorize({ scopes: ["scope.userInfo"] });
} catch (authErr) {
console.warn("[ZaloLogin] authorize scope.userInfo error or dismissed:", authErr);
}
try {
const rawInfo = (await getUserInfo({ autoRequestPermission: true })) as unknown as {
userInfo?: { id?: string; name?: string; avatar?: string };
id?: string;
name?: string;
avatar?: string;
};
const userObj = rawInfo?.userInfo || rawInfo;
if (userObj) {
if (userObj.id) zaloId = userObj.id;
if (userObj.name) name = userObj.name;
if (userObj.avatar) avatar = userObj.avatar;
}
} catch (infoErr) {
console.warn("[ZaloLogin] getUserInfo error:", infoErr);
}
// 3. Lấy số điện thoại từ Zalo SDK (lấy mã token bảo mật 2 phút để backend giải mã)
let phoneToken: string | undefined;
let phoneNumber: string | undefined;
try {
const phoneResult = await getPhoneNumber();
phoneToken = phoneResult.token;
phoneNumber = phoneResult.number;
} catch (phoneErr) {
console.warn("getPhoneNumber error:", phoneErr);
}
if (!phoneToken && !phoneNumber) {
openSnackbar({
type: "warning",
text: t("auth.zalo.phoneDenied"),
});
return;
}
// 4. Gửi lên backend để xác thực + giải mã SĐT + đăng nhập/tạo tài khoản
const response = await authService.loginWithZalo({
accessToken,
phoneToken,
phoneNumber,
zaloId,
name,
avatar,
});
if (response.success && response.data) {
setAuth(
response.data.user,
response.data.accessToken || "",
response.data.refreshToken || ""
);
openSnackbar({
type: "success",
text: t("auth.zalo.loginSuccess"),
});
navigate("/", { replace: true });
} else {
throw new Error(t("auth.zalo.loginFailed"));
}
} catch (error: unknown) {
openSnackbar({
type: "error",
text: getErrorMessage(error, t("auth.zalo.loginFailed")),
});
} finally {
setIsLoading(false);
}
};
return { handleZaloLogin, isLoading };
}
......@@ -171,13 +171,13 @@
}
},
"zalo": {
"loginButton": "Sign in with Zalo",
"loggingIn": "Signing in...",
"loginSuccess": "Signed in successfully!",
"loginFailed": "Sign-in failed, please try again.",
"loginButton": "Link Account",
"loggingIn": "Linking account...",
"loginSuccess": "Account linked successfully!",
"loginFailed": "Account linking failed, please try again.",
"tokenFailed": "Could not retrieve Zalo info, please try again.",
"phoneDenied": "Phone number permission is required to sign in.",
"termsNote": "By signing in, you agree to FinWise's terms of service."
"phoneDenied": "Phone number permission is required to link your account.",
"termsNote": "By linking, you agree to FinWise's terms of service."
},
"register": {
"header": "Sign Up",
......@@ -187,6 +187,10 @@
"fullNamePlaceholder": "John Doe",
"gmailLabel": "Email (@gmail.com only)",
"confirmPassword": "Confirm password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"showConfirmPassword": "Show confirm password",
"hideConfirmPassword": "Hide confirm password",
"submitting": "Creating account...",
"submit": "Sign Up",
"hasAccount": "Already have an account?",
......@@ -314,7 +318,23 @@
"avatarDeleteSuccess": "Profile picture deleted.",
"avatarTypeInvalid": "Profile picture must be JPEG, PNG, or WebP.",
"avatarSizeInvalid": "Profile picture cannot exceed 5 MB.",
"avatarReadFailed": "Could not read the selected image. Try another one."
"avatarReadFailed": "Could not read the selected image. Try another one.",
"zaloLink": {
"sectionTitle": "Zalo Account Link",
"sectionHint": "Link your Zalo phone number to your FinWise account for quick sign-in next time.",
"linked": "Linked",
"notLinked": "Not linked",
"linkBtn": "Link Zalo Account",
"unlinkBtn": "Unlink",
"linking": "Linking...",
"unlinking": "Unlinking...",
"linkSuccess": "Zalo account linked successfully!",
"linkFailed": "Linking failed, please try again.",
"unlinkSuccess": "Zalo account unlinked.",
"unlinkFailed": "Unlinking failed.",
"alreadyLinked": "This Zalo account is already linked to another FinWise account.",
"unlinkWarning": "After unlinking, you will need to sign in with email and password."
}
},
"avatarEditor": {
"title": "Edit Profile Picture",
......@@ -1269,11 +1289,22 @@
"channelHint": { "IN_APP": "Inbox and badge", "EMAIL": "Send to account email", "ZALO": "Personal Zalo Bot message", "PUSH": "Connection coming soon" },
"zaloBotLink": {
"title": "Link Zalo Bot",
"hint": "Send any message to Bot FinWise on Zalo, then enter the Chat ID you receive below.",
"placeholder": "e.g. 6ede9afa66b88fe6d6a9",
"hint": "Send a message to Bot FinWise on Zalo to automatically connect alerts.",
"placeholder": "e.g. dbcb8889dbdc32826bcd",
"linked": "Zalo Bot linked",
"unlink": "Unlink",
"saveChat": "Save Chat ID"
"saveChat": "Save Chat ID",
"oneClickBtn": "One-Click Link via Zalo Bot",
"oneClickTitle": "Link Zalo Bot (One-Click)",
"oneClickDesc": "Send this code to Bot Finwise on Zalo to automatically connect:",
"openZalo": "Open Zalo Chat",
"copyCode": "Copy Code",
"copied": "Code copied!",
"waiting": "Waiting for message on Zalo...",
"successLinked": "Zalo Bot linked successfully!",
"codeExpires": "Code expires in",
"manualToggle": "Or enter Chat ID manually",
"hideManual": "Hide manual input"
}
},
"reminder": {
......@@ -2169,7 +2200,8 @@
"USER_ACTIVATE": "Activate user",
"USER_DEACTIVATE": "Deactivate user",
"USER_DELETE": "Delete user",
"USER_RESTORE": "Restore user"
"USER_RESTORE": "Restore user",
"AUDIT_LOGS_ARCHIVE_CLEANUP": "Archive & clean up logs"
},
"targetType": "Target entity",
"targets": { "user": "User", "role": "Role", "permission": "Permission" },
......@@ -2182,7 +2214,15 @@
"actor": "Actor",
"system": "System",
"ipAddress": "IP address",
"userAgent": "User agent"
"userAgent": "User agent",
"retentionPolicyNotice": "Audit logs older than 30 days are automatically archived and cleaned up to optimize database storage.",
"archiveCleanupBtn": "Archive & Clean Up Now",
"archiveCleanupConfirmTitle": "Confirm Audit Log Archiving",
"archiveCleanupConfirmDesc": "The system will export all audit logs older than 30 days to a compressed archive file before purging them from the database. Are you sure you want to proceed?",
"archiveCleanupSuccess": "Archive and cleanup completed: {{count}} records.",
"archiveCleanupNoLogs": "No audit records older than 30 days found.",
"archiveCleanupProcessing": "Archiving and cleaning up...",
"archiveCleanupFailed": "Failed to archive and clean up audit logs."
},
"errors": {
"stats": "Could not load admin statistics.",
......
......@@ -171,13 +171,13 @@
}
},
"zalo": {
"loginButton": "Đăng nhập bằng Zalo",
"loggingIn": "Đang đăng nhập...",
"loginSuccess": "Đăng nhập thành công!",
"loginFailed": "Đăng nhập thất bại, vui lòng thử lại.",
"loginButton": "Liên kết tài khoản",
"loggingIn": "Đang liên kết...",
"loginSuccess": "Liên kết tài khoản thành công!",
"loginFailed": "Liên kết tài khoản thất bại, vui lòng thử lại.",
"tokenFailed": "Không thể lấy thông tin Zalo, vui lòng thử lại.",
"phoneDenied": "Bạn cần cấp quyền số điện thoại để đăng nhập.",
"termsNote": "Bằng cách đăng nhập, bạn đồng ý với điều khoản sử dụng của FinWise."
"phoneDenied": "Bạn cần cấp quyền số điện thoại để liên kết tài khoản.",
"termsNote": "Bằng cách liên kết, bạn đồng ý với điều khoản sử dụng của FinWise."
},
"register": {
"header": "Đăng Ký",
......@@ -187,6 +187,10 @@
"fullNamePlaceholder": "Nguyễn Văn A",
"gmailLabel": "Email (chỉ nhận @gmail.com)",
"confirmPassword": "Xác nhận mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"showConfirmPassword": "Hiện xác nhận mật khẩu",
"hideConfirmPassword": "Ẩn xác nhận mật khẩu",
"submitting": "Đang đăng ký...",
"submit": "Đăng Ký",
"hasAccount": "Đã có tài khoản?",
......@@ -318,7 +322,23 @@
"avatarDeleteSuccess": "Đã xóa ảnh đại diện.",
"avatarTypeInvalid": "Ảnh đại diện phải là JPEG, PNG hoặc WebP.",
"avatarSizeInvalid": "Ảnh đại diện không được vượt quá 5 MB.",
"avatarReadFailed": "Không thể đọc ảnh đã chọn. Vui lòng thử ảnh khác."
"avatarReadFailed": "Không thể đọc ảnh đã chọn. Vui lòng thử ảnh khác.",
"zaloLink": {
"sectionTitle": "Liên kết Zalo",
"sectionHint": "Liên kết số điện thoại Zalo với tài khoản FinWise để đăng nhập nhanh trong lần sau.",
"linked": "Đã liên kết",
"notLinked": "Chưa liên kết",
"linkBtn": "Liên kết tài khoản Zalo",
"unlinkBtn": "Huỷ liên kết",
"linking": "Đang liên kết...",
"unlinking": "Đang huỷ...",
"linkSuccess": "Liên kết tài khoản Zalo thành công!",
"linkFailed": "Liên kết thất bại, vui lòng thử lại.",
"unlinkSuccess": "Đã huỷ liên kết tài khoản Zalo.",
"unlinkFailed": "Huỷ liên kết thất bại.",
"alreadyLinked": "Tài khoản Zalo này đã được liên kết với một tài khoản khác.",
"unlinkWarning": "Sau khi huỷ, bạn cần đăng nhập bằng email và mật khẩu."
}
},
"avatarEditor": {
"title": "Chỉnh ảnh đại diện",
......@@ -1333,11 +1353,22 @@
},
"zaloBotLink": {
"title": "Liên kết Zalo Bot",
"hint": "Nhắn tin bất kỳ cho Bot FinWise trên Zalo, sau đó nhập Chat ID nhận được vào ô bên dưới.",
"placeholder": "Ví dụ: 6ede9afa66b88fe6d6a9",
"hint": "Gửi tin nhắn cho Bot FinWise để kết nối nhận thông báo tự động.",
"placeholder": "Ví dụ: dbcb8889dbdc32826bcd",
"linked": "Đã liên kết Zalo Bot",
"unlink": "Hủy liên kết",
"saveChat": "Lưu Chat ID"
"saveChat": "Lưu Chat ID",
"oneClickBtn": "Kết nối 1 chạm với Zalo Bot",
"oneClickTitle": "Kết Nối Zalo Bot (1 Chạm)",
"oneClickDesc": "Nhắn mã bên dưới cho Bot Finwise để hoàn tất kết nối tự động:",
"openZalo": "Mở khung chat Zalo Bot",
"copyCode": "Sao chép mã",
"copied": "Đã sao chép mã!",
"waiting": "Đang chờ nhận tin nhắn từ Zalo...",
"successLinked": "Đã liên kết Zalo Bot thành công!",
"codeExpires": "Mã hết hạn sau",
"manualToggle": "Hoặc nhập Chat ID thủ công",
"hideManual": "Ẩn nhập thủ công"
}
},
"reminder": {
......@@ -2270,7 +2301,8 @@
"USER_ACTIVATE": "Kích hoạt người dùng",
"USER_DEACTIVATE": "Vô hiệu hóa người dùng",
"USER_DELETE": "Xóa người dùng",
"USER_RESTORE": "Khôi phục người dùng"
"USER_RESTORE": "Khôi phục người dùng",
"AUDIT_LOGS_ARCHIVE_CLEANUP": "Lưu trữ & dọn dẹp log"
},
"targetType": "Loại đối tượng",
"targets": { "user": "Người dùng", "role": "Vai trò", "permission": "Quyền hạn" },
......@@ -2283,7 +2315,15 @@
"actor": "Người thực hiện",
"system": "Hệ thống",
"ipAddress": "Địa chỉ IP",
"userAgent": "Thiết bị truy cập"
"userAgent": "Thiết bị truy cập",
"retentionPolicyNotice": "Nhật ký kiểm toán được tự động nén lưu trữ và dọn dẹp sau 30 ngày để tối ưu dung lượng hệ thống.",
"archiveCleanupBtn": "Dọn dẹp & Lưu trữ ngay",
"archiveCleanupConfirmTitle": "Xác nhận dọn dẹp nhật ký kiểm toán",
"archiveCleanupConfirmDesc": "Hệ thống sẽ kết xuất toàn bộ bản ghi kiểm toán cũ hơn 30 ngày thành file nén lưu trữ trước khi dọn dẹp khỏi cơ sở dữ liệu. Bạn có chắc chắn muốn thực hiện?",
"archiveCleanupSuccess": "Đã hoàn tất lưu trữ và dọn dẹp: {{count}} bản ghi.",
"archiveCleanupNoLogs": "Không có bản ghi kiểm toán nào cũ hơn 30 ngày.",
"archiveCleanupProcessing": "Đang nén lưu trữ và dọn dẹp...",
"archiveCleanupFailed": "Quá trình lưu trữ và dọn dẹp thất bại."
},
"errors": {
"stats": "Không thể tải thống kê quản trị.",
......
import React, { useMemo, useState } from 'react';
import { Header, Page, useNavigate } from 'zmp-ui';
import { Header, Page, useNavigate, useSnackbar } from 'zmp-ui';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Modal } from '@/components/ui/Modal';
import { Select } from '@/components/ui/Select';
import { LocalizedDateInput } from '@/components/shared/LocalizedDateInput';
import { addCalendarDays, businessDateStartIso } from '@/lib/business-time';
import { useAdminAuditLogs } from '@/hooks/use-admin';
import { useAdminAuditLogs, useArchiveCleanupAuditLogs } from '@/hooks/use-admin';
import { useI18n } from '@/i18n';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
......@@ -22,6 +23,7 @@ const AUDIT_ACTIONS = [
'USER_DEACTIVATE',
'USER_DELETE',
'USER_RESTORE',
'AUDIT_LOGS_ARCHIVE_CLEANUP',
] as const;
function endOfBusinessDateIso(date: string): string {
......@@ -31,14 +33,41 @@ function endOfBusinessDateIso(date: string): string {
const AdminAuditLogsPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const { t, formatNumber } = useI18n();
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [page, setPage] = useState(1);
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const invalidRange = Boolean(dateFrom && dateTo && dateFrom > dateTo);
const archiveMutation = useArchiveCleanupAuditLogs();
const handleArchiveCleanup = async () => {
try {
const res = await archiveMutation.mutateAsync(30);
setIsConfirmOpen(false);
if (res.data?.archivedCount && res.data.archivedCount > 0) {
openSnackbar({
type: 'success',
text: t('admin.audit.archiveCleanupSuccess', { count: res.data.archivedCount }),
});
} else {
openSnackbar({
type: 'info',
text: t('admin.audit.archiveCleanupNoLogs'),
});
}
} catch {
openSnackbar({
type: 'error',
text: t('admin.audit.archiveCleanupFailed'),
});
}
};
const params = useMemo(() => ({
dateFrom: dateFrom ? businessDateStartIso(dateFrom) : undefined,
dateTo: dateTo ? endOfBusinessDateIso(dateTo) : undefined,
......@@ -63,6 +92,35 @@ const AdminAuditLogsPage: React.FC = () => {
<Page className="page min-h-screen bg-clay-bg pb-12">
<Header title={t('admin.audit.title')} showBackIcon onBackClick={() => navigate('/admin')} />
<main className="mx-auto w-full max-w-5xl space-y-5 px-4 pt-6">
{/* Retention Policy Banner */}
<Card className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 border border-clay-info/30 bg-clay-info/10 p-4 shadow-clay-raised">
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 flex-none items-center justify-center rounded-full bg-clay-info/20 text-clay-info shadow-clay-sm">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="space-y-0.5">
<p className="font-semibold text-sm text-clay-text">
{t('admin.audit.title')} — 30 ngày
</p>
<p className="text-xs text-clay-text-muted leading-relaxed">
{t('admin.audit.retentionPolicyNotice')}
</p>
</div>
</div>
<Button
variant="secondary"
className="text-xs whitespace-nowrap self-end sm:self-center px-3 py-2"
disabled={archiveMutation.isPending}
onClick={() => setIsConfirmOpen(true)}
>
{archiveMutation.isPending
? t('admin.audit.archiveCleanupProcessing')
: t('admin.audit.archiveCleanupBtn')}
</Button>
</Card>
<Card>
<h1 className="clay-title-h2">{t('admin.audit.heading')}</h1>
<p className="mt-1 text-sm text-clay-text-muted">{t('admin.audit.description')}</p>
......@@ -138,6 +196,32 @@ const AdminAuditLogsPage: React.FC = () => {
</Button>
</nav>
)}
<Modal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
title={t('admin.audit.archiveCleanupConfirmTitle')}
footer={
<>
<Button variant="ghost" onClick={() => setIsConfirmOpen(false)}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={archiveMutation.isPending}
onClick={handleArchiveCleanup}
>
{archiveMutation.isPending
? t('admin.audit.archiveCleanupProcessing')
: t('admin.audit.archiveCleanupBtn')}
</Button>
</>
}
>
<p className="text-sm text-clay-text-muted leading-relaxed">
{t('admin.audit.archiveCleanupConfirmDesc')}
</p>
</Modal>
</main>
</Page>
);
......
......@@ -8,7 +8,6 @@ import { Select } from '@/components/ui/Select';
import { PermissionGate } from '@/components/shared/PermissionGate';
import { PERMISSIONS } from '@/common/constants';
import {
useAdminAuditLogs,
useAdminRoles,
useAdminUser,
useRestoreAdminUser,
......@@ -18,7 +17,6 @@ import { usePermission } from '@/hooks/use-permission';
import { useI18n } from '@/i18n';
import { getErrorMessage } from '@/lib/error-message';
import { AdminSkeleton } from '@/pages/admin/components/AdminSkeleton';
import { AuditLogCard } from '@/pages/admin/components/AuditLogCard';
import { useAuthStore } from '@/stores/auth-store';
const AdminUserDetailPage: React.FC = () => {
......@@ -28,10 +26,8 @@ const AdminUserDetailPage: React.FC = () => {
const { t, formatDate } = useI18n();
const currentUser = useAuthStore((state) => state.user);
const { hasPermission } = usePermission();
const canReadAuditLogs = hasPermission(PERMISSIONS.AUDIT_LOG_READ);
const userQuery = useAdminUser(id);
const rolesQuery = useAdminRoles();
const auditQuery = useAdminAuditLogs({ targetId: id, limit: 20 }, canReadAuditLogs && Boolean(id));
const updateMutation = useUpdateAdminUser();
const restoreMutation = useRestoreAdminUser();
const user = userQuery.data?.success ? userQuery.data.data : undefined;
......@@ -138,24 +134,6 @@ const AdminUserDetailPage: React.FC = () => {
</PermissionGate>
)}
</Card>
<PermissionGate permission={PERMISSIONS.AUDIT_LOG_READ}>
<section aria-labelledby="user-audit-heading">
<h2 id="user-audit-heading" className="clay-title-h3 mb-3">{t('admin.userDetail.history')}</h2>
{auditQuery.isLoading ? (
<AdminSkeleton rows={2} />
) : auditQuery.isError ? (
<Card className="text-center">
<p className="text-clay-expense">{t('admin.errors.auditLogs')}</p>
<Button className="mt-4" onClick={() => auditQuery.refetch()}>{t('common.retry')}</Button>
</Card>
) : (auditQuery.data?.data.length ?? 0) === 0 ? (
<Card className="text-center text-clay-text-muted">{t('admin.audit.empty')}</Card>
) : (
<div className="space-y-3">{auditQuery.data?.data.map((log) => <AuditLogCard key={log.id} log={log} />)}</div>
)}
</section>
</PermissionGate>
</>
)}
</main>
......
......@@ -61,21 +61,22 @@ const ForgotPasswordPage: React.FC = () => {
};
return (
<Page className="page flex flex-col justify-start overflow-y-auto">
<Page className="page flex flex-col overflow-y-auto">
<Header title={t("auth.forgot.header")} showBackIcon={true} onBackClick={() => navigate("/login")} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-6 px-2 pt-2">
<div className="flex flex-col items-center gap-2.5 text-center">
<div className="flex flex-1 items-center justify-center px-4 py-6">
<div className="w-full max-w-sm flex flex-col gap-5">
<div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" />
<div className="space-y-1">
<h1 className="clay-title-h1 text-clay-primary">{t("auth.forgot.title")}</h1>
<p className="clay-caption">{t("auth.forgot.subtitle")}</p>
<h1 className="clay-title-h1 text-clay-primary text-2xl sm:text-3xl">{t("auth.forgot.title")}</h1>
<p className="clay-caption text-xs sm:text-sm">{t("auth.forgot.subtitle")}</p>
</div>
</div>
{isSent ? (
<Card className="text-center space-y-4">
<Card className="p-5 text-center space-y-4 shadow-clay-raised">
<div className="w-16 h-16 bg-clay-info/20 text-clay-info rounded-full flex items-center justify-center mx-auto shadow-clay-raised border border-clay-highlight">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
......@@ -85,24 +86,26 @@ const ForgotPasswordPage: React.FC = () => {
<p className="clay-body text-sm">
{t("auth.forgot.sentDescription")}
</p>
<Button variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
<Button id="btn-forgot-enter-code" variant="primary" fullWidth onClick={() => navigate("/reset-password")}>
{t("auth.forgot.enterResetCode")}
</Button>
</Card>
) : (
<>
<Card>
<Card className="p-5 flex flex-col gap-4 shadow-clay-raised">
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
id="forgot-email-input"
label={t("auth.forgot.linkedEmail")}
placeholder="user@gmail.com"
type="email"
autoComplete="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
<Button id="btn-forgot-submit" variant="primary" type="submit" fullWidth disabled={isLoading} className="mt-1">
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
......@@ -119,7 +122,7 @@ const ForgotPasswordPage: React.FC = () => {
{t("auth.forgot.rememberPassword")}{" "}
<span
onClick={() => navigate("/login")}
className="text-clay-primary font-bold hover:underline cursor-pointer"
className="text-clay-primary font-bold hover:underline cursor-pointer select-none"
>
{t("auth.forgot.login")}
</span>
......@@ -127,6 +130,7 @@ const ForgotPasswordPage: React.FC = () => {
</>
)}
</div>
</div>
</Page>
);
};
......
......@@ -12,7 +12,6 @@ import {
EyeOffIcon,
} from "@/components/ui/icons";
import { Logo } from "@/components/logo";
import { useZaloLogin } from "@/hooks/use-zalo-login";
import { useAuthStore } from "@/stores/auth-store";
import { authService } from "@/services/auth.service";
import { getErrorMessage } from "@/lib/error-message";
......@@ -46,7 +45,6 @@ const LoginPage: React.FC = () => {
const navigate = useNavigate();
const { openSnackbar } = useSnackbar();
const setAuth = useAuthStore((state) => state.setAuth);
const { handleZaloLogin, isLoading: isZaloLoading } = useZaloLogin();
const { t } = useI18n();
const [isSubmitting, setIsSubmitting] = useState(false);
......@@ -54,6 +52,8 @@ const LoginPage: React.FC = () => {
const [rememberedEmail] = useState(getRememberedEmail);
const loginSchema = useMemo(() => createLoginSchema(t), [t]);
const isBusy = isSubmitting;
const {
register,
handleSubmit,
......@@ -103,14 +103,13 @@ const LoginPage: React.FC = () => {
}
};
const isBusy = isSubmitting || isZaloLoading;
return (
<Page className="page flex flex-col justify-start overflow-y-auto">
<Page className="page flex flex-col overflow-y-auto">
<Header title={t("auth.login.header")} showBackIcon={false} />
<IconGradients />
<div className="w-full max-w-sm mx-auto flex flex-col gap-5 pt-2">
<div className="flex flex-1 items-center justify-center px-4 py-6">
<div className="w-full max-w-sm flex flex-col gap-5">
{/* Top: Branding */}
<div className="flex flex-col items-center gap-2 text-center">
<Logo size={64} alt="FinWise" />
......@@ -142,7 +141,7 @@ const LoginPage: React.FC = () => {
autoComplete="current-password"
error={errors.password?.message}
{...register("password")}
disabled={isBusy}
disabled={isSubmitting}
endAdornment={
<button
type="button"
......@@ -153,7 +152,7 @@ const LoginPage: React.FC = () => {
)}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isBusy}
disabled={isSubmitting}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
......@@ -166,7 +165,7 @@ const LoginPage: React.FC = () => {
<input
type="checkbox"
{...register("rememberMe")}
disabled={isBusy}
disabled={isSubmitting}
className="h-4 w-4 cursor-pointer rounded border-clay-text-muted accent-clay-primary transition-all duration-200 ease-in-out focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
/>
<span>{t("auth.login.rememberEmail")}</span>
......@@ -197,58 +196,6 @@ const LoginPage: React.FC = () => {
)}
</Button>
</form>
{/* Divider */}
<div className="relative my-1 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-clay-highlight/50" />
</div>
<div className="relative bg-clay-surface px-3 font-nunito text-xs font-semibold text-clay-text-muted uppercase tracking-wider">
{t("auth.login.or") || "Hoặc"}
</div>
</div>
{/* Zalo 1-Click Login Button */}
<button
id="btn-zalo-login"
type="button"
onClick={handleZaloLogin}
disabled={isBusy}
className="relative flex w-full items-center justify-center gap-2.5 rounded-clay bg-[#0068FF] px-4 py-3 font-nunito text-sm font-bold text-white shadow-clay-raised transition-all duration-200 ease-in-out hover:-translate-y-0.5 hover:shadow-clay-hover active:translate-y-0 active:shadow-clay-pressed disabled:cursor-not-allowed disabled:opacity-70 select-none"
>
{isZaloLoading ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>{t("auth.zalo.loggingIn")}</span>
</>
) : (
<>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect width="24" height="24" rx="6" fill="white" />
<text
x="12"
y="12"
textAnchor="middle"
dominantBaseline="central"
fontSize="14"
fontWeight="900"
fontFamily="Nunito, system-ui, sans-serif"
fill="#0068FF"
>
Z
</text>
</svg>
<span>{t("auth.zalo.loginButton")}</span>
</>
)}
</button>
</Card>
{/* Bottom: Link to Register */}
......@@ -261,11 +208,7 @@ const LoginPage: React.FC = () => {
{t("auth.login.registerNow")}
</span>
</div>
{/* Terms note */}
<p className="text-center font-nunito text-xs text-clay-text-muted px-4">
{t("auth.zalo.termsNote")}
</p>
</div>
</div>
</Page>
);
......
......@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input";
import { authService } from "@/services/auth.service";
import { IconGradients } from "@/components/ui/icons";
import { IconGradients, EyeIcon, EyeOffIcon } from "@/components/ui/icons";
import { Logo } from "@/components/logo";
import { TranslationFunction, useI18n } from "@/i18n";
import { getErrorMessage } from "@/lib/error-message";
......@@ -43,6 +43,8 @@ const RegisterPage: React.FC = () => {
const { openSnackbar } = useSnackbar();
const [isLoading, setIsLoading] = useState(false);
const [isRegistered, setIsRegistered] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isConfirmPasswordVisible, setIsConfirmPasswordVisible] = useState(false);
const { t } = useI18n();
const registerSchema = useMemo(() => createRegisterSchema(t), [t]);
......@@ -119,42 +121,82 @@ const RegisterPage: React.FC = () => {
<Card>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<Input
id="register-fullname-input"
label={t("auth.register.fullName")}
placeholder={t("auth.register.fullNamePlaceholder")}
type="text"
autoComplete="name"
error={errors.fullName?.message}
{...register("fullName")}
disabled={isLoading}
/>
<Input
id="register-email-input"
label={t("auth.register.gmailLabel")}
placeholder="user@gmail.com"
type="email"
autoComplete="email"
error={errors.email?.message}
{...register("email")}
disabled={isLoading}
/>
<Input
id="register-password-input"
label={t("auth.password")}
placeholder="••••••••"
type="password"
type={isPasswordVisible ? "text" : "password"}
autoComplete="new-password"
error={errors.password?.message}
{...register("password")}
disabled={isLoading}
endAdornment={
<button
type="button"
aria-label={t(
isPasswordVisible
? "auth.register.hidePassword"
: "auth.register.showPassword"
)}
aria-pressed={isPasswordVisible}
onClick={() => setIsPasswordVisible((visible) => !visible)}
disabled={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
<Input
id="register-confirm-password-input"
label={t("auth.register.confirmPassword")}
placeholder="••••••••"
type="password"
type={isConfirmPasswordVisible ? "text" : "password"}
autoComplete="new-password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
disabled={isLoading}
endAdornment={
<button
type="button"
aria-label={t(
isConfirmPasswordVisible
? "auth.register.hideConfirmPassword"
: "auth.register.showConfirmPassword"
)}
aria-pressed={isConfirmPasswordVisible}
onClick={() => setIsConfirmPasswordVisible((visible) => !visible)}
disabled={isLoading}
className="flex h-8 w-8 items-center justify-center rounded-full text-clay-text-muted transition-all duration-200 ease-in-out hover:bg-clay-primary/10 hover:text-clay-primary focus:outline-none focus:ring-2 focus:ring-clay-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
{isConfirmPasswordVisible ? <EyeOffIcon /> : <EyeIcon />}
</button>
}
/>
<Button variant="primary" type="submit" fullWidth disabled={isLoading}>
<Button id="btn-register-submit" variant="primary" type="submit" fullWidth disabled={isLoading}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-clay-on-primary border-t-transparent rounded-full animate-spin"></div>
......
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { NotificationChannel, NotificationSetting } from "@/types/notification";
import { useI18n } from "@/i18n";
import { zaloBotService, ZaloLinkCodeData } from "@/services/zalo-bot.service";
interface NotificationSettingsProps {
setting: NotificationSetting;
......@@ -17,11 +18,65 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
const [draft, setDraft] = useState(setting);
const [chatIdInput, setChatIdInput] = useState(setting.zaloBotChatId ?? "");
// State cho luồng kết nối 1 chạm (Phase 2)
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
const [linkCodeData, setLinkCodeData] = useState<ZaloLinkCodeData | null>(null);
const [timeLeft, setTimeLeft] = useState<number>(0);
const [isCopied, setIsCopied] = useState(false);
const [showManualInput, setShowManualInput] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setDraft(setting);
setChatIdInput(setting.zaloBotChatId ?? "");
}, [setting]);
// Bộ đếm ngược thời gian hết hạn mã liên kết
useEffect(() => {
if (!linkCodeData || timeLeft <= 0) return;
const timer = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
setLinkCodeData(null);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [linkCodeData, timeLeft]);
// Auto-polling trạng thái kết nối khi modal mã liên kết đang mở
useEffect(() => {
if (!linkCodeData) {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
return;
}
pollTimerRef.current = setInterval(async () => {
try {
const status = await zaloBotService.getLinkStatus();
if (status.linked && status.zaloBotChatId) {
// Tự động cập nhật trạng thái đã liên kết
setDraft((prev) => ({
...prev,
zaloBotChatId: status.zaloBotChatId,
channels: Array.from(new Set([...prev.channels, "ZALO" as NotificationChannel])),
}));
setChatIdInput(status.zaloBotChatId);
setLinkCodeData(null);
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
}
} catch {
// Bỏ qua lỗi polling mạng tạm thời
}
}, 2500);
return () => {
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
};
}, [linkCodeData]);
const toggleChannel = (channel: NotificationChannel) => {
setDraft((current) => {
const selected = current.channels.includes(channel);
......@@ -30,16 +85,56 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
});
};
const handleCreateLinkCode = async () => {
setIsGeneratingCode(true);
try {
const data = await zaloBotService.createLinkCode();
setLinkCodeData(data);
setTimeLeft(data.expiresInSeconds);
setIsCopied(false);
} catch (err: any) {
console.error("Failed to generate link code:", err);
} finally {
setIsGeneratingCode(false);
}
};
const handleCopyCode = async () => {
if (!linkCodeData) return;
try {
await navigator.clipboard.writeText(linkCodeData.linkCode);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch {
// Fallback nếu clipboard API bị chặn
}
};
const handleUnlink = async () => {
try {
await zaloBotService.unlink();
setChatIdInput("");
setDraft((prev) => ({ ...prev, zaloBotChatId: null }));
} catch (err: any) {
console.error("Failed to unlink Zalo bot:", err);
}
};
const handleSave = () => {
onSave({
...draft,
// Chỉ gửi zaloBotChatId khi kênh ZALO được bật
zaloBotChatId: draft.channels.includes("ZALO") ? (chatIdInput.trim() || null) : draft.zaloBotChatId,
});
};
const isZaloEnabled = draft.channels.includes("ZALO");
const isZaloLinked = !!draft.zaloBotChatId;
const isZaloLinked = Boolean(draft.zaloBotChatId);
const formatSeconds = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
const toggles: Array<{ key: keyof Omit<NotificationSetting, "channels" | "zaloBotChatId">; title: string; hint: string }> = [
{ key: "budgetAlertsEnabled", title: t("notification.settings.budget"), hint: t("notification.settings.budgetHint") },
......@@ -83,7 +178,13 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
<div className="flex-1">
<h2 className="clay-title-h3">{t("notification.zaloBotLink.title")}</h2>
{isZaloLinked ? (
<p className="clay-caption mt-1 font-semibold text-green-600">{t("notification.zaloBotLink.linked")}</p>
<div className="mt-1 flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2.5 py-0.5 font-nunito text-xs font-bold text-green-700">
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse"></span>
{t("notification.zaloBotLink.linked")}
</span>
<span className="clay-caption text-xs text-clay-text/60">({draft.zaloBotChatId})</span>
</div>
) : (
<p className="clay-caption mt-1">{t("notification.zaloBotLink.hint")}</p>
)}
......@@ -91,37 +192,129 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({ sett
</div>
<div className="mt-4 flex flex-col gap-3">
<input
id="zalo-bot-chat-id"
type="text"
value={chatIdInput}
onChange={(e) => setChatIdInput(e.target.value)}
placeholder={t("notification.zaloBotLink.placeholder")}
disabled={isSaving}
maxLength={100}
className="w-full rounded-clay-sm border border-clay-border bg-clay-bg px-3 py-2 font-nunito text-sm text-clay-text shadow-clay-pressed placeholder:text-clay-text/40 focus:border-clay-primary focus:outline-none disabled:opacity-50"
/>
{/* TH1: Chưa liên kết -> Hiện nút kết nối 1 chạm */}
{!isZaloLinked && !linkCodeData && (
<div className="flex flex-col gap-2">
<Button
type="button"
variant="primary"
fullWidth
disabled={isGeneratingCode || isSaving}
onClick={handleCreateLinkCode}
className="bg-blue-600 text-white hover:bg-blue-700 shadow-clay-raised"
>
{isGeneratingCode ? t("common.loading") : t("notification.zaloBotLink.oneClickBtn")}
</Button>
</div>
)}
{/* TH2: Đang hiển thị Modal mã liên kết 1 chạm */}
{linkCodeData && (
<div className="rounded-clay-md border border-blue-200 bg-blue-50/70 p-4 shadow-clay-pressed flex flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="font-nunito text-sm font-bold text-blue-900">
{t("notification.zaloBotLink.oneClickTitle")}
</h4>
<span className="rounded bg-blue-200/80 px-2 py-0.5 font-mono text-xs font-bold text-blue-800">
{t("notification.zaloBotLink.codeExpires")}: {formatSeconds(timeLeft)}
</span>
</div>
<p className="clay-caption text-xs text-blue-800">
{t("notification.zaloBotLink.oneClickDesc")}
</p>
{/* Box hiển thị mã lớn */}
<div className="flex items-center justify-between rounded-clay-sm bg-white p-3 border border-blue-200 shadow-clay-pressed">
<span className="font-mono text-xl font-extrabold tracking-widest text-blue-900">
{linkCodeData.linkCode}
</span>
<button
type="button"
onClick={handleCopyCode}
className="rounded-clay-sm bg-blue-100 px-2.5 py-1 text-xs font-bold text-blue-700 hover:bg-blue-200 transition-colors"
>
{isCopied ? t("notification.zaloBotLink.copied") : t("notification.zaloBotLink.copyCode")}
</button>
</div>
{/* Nút mở thẳng sang Zalo */}
<div className="flex gap-2">
<a
href={linkCodeData.deepLinkUrl}
target="_blank"
rel="noreferrer"
className="flex-1 rounded-clay-sm bg-blue-600 px-3 py-2 text-center font-nunito text-xs font-bold text-white shadow-clay-raised hover:bg-blue-700 transition-colors"
>
{t("notification.zaloBotLink.openZalo")}
</a>
<button
type="button"
onClick={() => setLinkCodeData(null)}
className="rounded-clay-sm border border-gray-300 bg-white px-3 py-2 font-nunito text-xs font-semibold text-gray-600 hover:bg-gray-50"
>
{t("common.cancel")}
</button>
</div>
<div className="flex items-center justify-center gap-2 pt-1">
<span className="h-2 w-2 rounded-full bg-blue-600 animate-ping"></span>
<span className="clay-caption text-xs text-blue-700 font-medium">
{t("notification.zaloBotLink.waiting")}
</span>
</div>
</div>
)}
{/* TH3: Đã liên kết -> Hiện nút Hủy liên kết */}
{isZaloLinked && (
<div className="flex items-center justify-between gap-3 pt-1">
<span className="clay-caption text-xs text-green-700 font-semibold">
{t("notification.zaloBotLink.successLinked")}
</span>
<button
type="button"
disabled={isSaving}
onClick={() => {
setChatIdInput("");
setDraft((prev) => ({ ...prev, zaloBotChatId: null }));
}}
onClick={handleUnlink}
className="rounded-clay-sm border border-red-300 bg-red-50 px-3 py-1.5 font-nunito text-xs font-bold text-red-600 shadow-clay-pressed transition-all duration-200 hover:bg-red-100 disabled:opacity-50"
>
{t("notification.zaloBotLink.unlink")}
</button>
</div>
)}
{/* Mục nhập thủ công (Dành cho quản trị viên hoặc fallback) */}
<div className="border-t border-clay-border/50 pt-2">
<button
type="button"
onClick={() => setShowManualInput((prev) => !prev)}
className="clay-caption text-xs text-clay-primary hover:underline"
>
{showManualInput ? t("notification.zaloBotLink.hideManual") : t("notification.zaloBotLink.manualToggle")}
</button>
{showManualInput && (
<div className="mt-2 flex flex-col gap-2">
<input
id="zalo-bot-chat-id"
type="text"
value={chatIdInput}
onChange={(e) => setChatIdInput(e.target.value)}
placeholder={t("notification.zaloBotLink.placeholder")}
disabled={isSaving}
maxLength={100}
className="w-full rounded-clay-sm border border-clay-border bg-clay-bg px-3 py-2 font-nunito text-xs text-clay-text shadow-clay-pressed placeholder:text-clay-text/40 focus:border-clay-primary focus:outline-none disabled:opacity-50"
/>
</div>
)}
</div>
</div>
</Card>
)}
<Button fullWidth disabled={isSaving} onClick={handleSave}>{isSaving ? t("common.saving") : t("notification.settings.save")}</Button>
<Button fullWidth disabled={isSaving} onClick={handleSave}>
{isSaving ? t("common.saving") : t("notification.settings.save")}
</Button>
</div>
);
};
......@@ -29,10 +29,6 @@ const AVATAR_ACCEPT = "image/jpeg,image/png,image/webp";
// Zod schemas for forms
const createProfileSchema = (t: TranslationFunction) => z.object({
fullName: z.string().min(2, t("validation.fullNameMin")),
phoneNumber: z
.string()
.regex(/^(0[3|5|7|8|9])+([0-9]{8})$/, t("validation.phoneInvalid"))
.or(z.literal("")),
});
const createPasswordSchema = (t: TranslationFunction) => z
......@@ -117,7 +113,6 @@ const ProfilePage: React.FC = () => {
resolver: zodResolver(profileSchema),
defaultValues: {
fullName: user?.fullName || "",
phoneNumber: user?.phoneNumber || "",
},
});
......@@ -125,23 +120,18 @@ const ProfilePage: React.FC = () => {
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]);
return currFullName !== origFullName;
}, [user, watchedFullName]);
const {
register: registerPassword,
......@@ -178,7 +168,6 @@ const ProfilePage: React.FC = () => {
setUser(response.data);
resetProfileForm({
fullName: response.data.fullName || "",
phoneNumber: response.data.phoneNumber || "",
});
}
},
......@@ -524,14 +513,6 @@ const ProfilePage: React.FC = () => {
disabled={updateProfileMutation.isPending}
/>
<Input
label={t("profile.phone")}
placeholder={t("profile.phonePlaceholder")}
error={profileErrors.phoneNumber?.message}
{...registerProfile("phoneNumber")}
disabled={updateProfileMutation.isPending}
/>
<Button
variant="primary"
type="submit"
......
......@@ -100,4 +100,19 @@ export const authService = {
const response = await apiClient.post("/auth/zalo-login", data);
return response.data;
},
async linkZaloAccount(data: {
accessToken: string;
zaloId?: string;
name?: string;
avatar?: string;
}): Promise<ApiResponse<User>> {
const response = await apiClient.post("/auth/zalo-link", data);
return response.data;
},
async unlinkZaloAccount(): Promise<ApiResponse<User>> {
const response = await apiClient.delete("/auth/zalo-link");
return response.data;
},
};
......@@ -113,4 +113,17 @@ export const rbacService = {
const response = await apiClient.get('/audit-logs', { params });
return response.data;
},
async archiveAndCleanupAuditLogs(retentionDays?: number): Promise<
ApiResponse<{
archivedCount: number;
cutoffDate: string;
retentionDays: number;
archiveFileName?: string;
r2Uploaded?: boolean;
}>
> {
const response = await apiClient.post('/audit-logs/archive-cleanup', { retentionDays });
return response.data;
},
};
import { apiClient } from "@/lib/api-client";
export interface ZaloLinkCodeData {
linkCode: string;
expiresInSeconds: number;
botUsername: string;
botDisplayName: string;
deepLinkUrl: string;
instruction: string;
}
export interface ZaloLinkStatusData {
linked: boolean;
zaloBotChatId: string | null;
channels: string[];
}
export const zaloBotService = {
async createLinkCode(): Promise<ZaloLinkCodeData> {
const response = await apiClient.post<{ success: boolean; data: ZaloLinkCodeData }>("/zalo-bot/link-code");
return response.data.data;
},
async getLinkStatus(): Promise<ZaloLinkStatusData> {
const response = await apiClient.get<{ success: boolean; data: ZaloLinkStatusData }>("/zalo-bot/link-status");
return response.data.data;
},
async unlink(): Promise<boolean> {
const response = await apiClient.post<{ success: boolean }>("/zalo-bot/unlink");
return response.data.success;
},
};
......@@ -44,6 +44,7 @@ export interface User {
role: Role;
permissions?: string[];
isActive: boolean;
zaloLinked?: boolean; // true nếu đã liên kết tài khoản Zalo
createdAt: string;
updatedAt: string;
}
......
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