diff --git a/api.ts b/api.ts index bedfaab..e9ae500 100644 --- a/api.ts +++ b/api.ts @@ -18,9 +18,15 @@ export const API = { CREATEOTP: `${API_URL_ROOT}/auth/token/create`, VERIFYOTP: `${API_URL_ROOT}/auth/token/verify`, REFRESH: `${API_URL_ROOT}/auth/refresh`, - GOOGLE_LOGIN: `${API_URL_ROOT}/auth/google/login` + GOOGLE_LOGIN: `${API_URL_ROOT}/auth/google/login`, + FORGOT_PASSWORD: `${API_URL_ROOT}/auth/forgot-password`, }, Admin:{ GET_LIST_USERS: `${API_URL_ROOT}/users`, + CHANGE_ROLE: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/role`, + DELETE_USER: (Id: number | string) => `${API_URL_ROOT}/users/${Id}`, + RESTORE_USER: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/restore`, + GET_USER_MEDIA: (Id: number | string) => `${API_URL_ROOT}/users/${Id}/media`, + GET_ALL_ROLE: `${API_URL_ROOT}/roles` } } \ No newline at end of file diff --git a/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx b/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx index 2b1ac05..ebb996b 100644 --- a/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx +++ b/src/app/(admin)/(others-pages)/(tables)/user-table/page.tsx @@ -2,39 +2,48 @@ import ComponentCard from "@/components/common/ComponentCard"; import PageBreadcrumb from "@/components/common/PageBreadCrumb"; import BasicTableOne from "@/components/tables/BasicTableOne"; -import { responseUserTable, getUserDto } from "@/interface/admin"; -import { apiGetListUser } from "@/service/adminService"; +import ChangeRoleModal from "@/components/tables/ChangeRoleModal"; +import UserDetailModal from "@/components/tables/UserDetailModal"; +import { Modal } from "@/components/ui/modal"; +import { responseUserTable, getUserDto, fullDataUser } from "@/interface/admin"; +import { + apiDeleteUser, + apiGetListUser, + apiRestoreUser, +} from "@/service/adminService"; import { useEffect, useState, useCallback } from "react"; +import { toast } from "sonner"; export type SortColumn = "created_at" | "updated_at" | "display_name" | "email"; export default function UserTable() { - // --- States cho Pagination --- - const [limit, setLimit] = useState(5); // Default theo ảnh là 5 + const [limit, setLimit] = useState(5); const [limitInput, setLimitInput] = useState("5"); const [page, setPage] = useState(1); - // --- States cho Filter (Theo ảnh Swagger) --- const [searchTerm, setSearchTerm] = useState(""); const [authProvider, setAuthProvider] = useState(""); const [createdFrom, setCreatedFrom] = useState(""); const [createdTo, setCreatedTo] = useState(""); const [isDeleted, setIsDeleted] = useState(undefined); - - // Debounced states + + const [selectedUser, setSelectedUser] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const [roleUser, setRoleUser] = useState(null); + const [isRoleModalOpen, setIsRoleModalOpen] = useState(false); + const [debouncedParams, setDebouncedParams] = useState({ search: "", limit: 5, authProvider: "", }); - // --- States cho Table --- const [tableData, setTableData] = useState(null); const [loading, setLoading] = useState(true); const [sortBy, setSortBy] = useState(undefined); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - // 1. Xử lý Debounce cho các ô input text useEffect(() => { const handler = setTimeout(() => { setDebouncedParams({ @@ -42,12 +51,11 @@ export default function UserTable() { limit: parseInt(limitInput) || 5, authProvider: authProvider, }); - setPage(1); // Reset về trang 1 khi filter thay đổi + setPage(1); }, 600); return () => clearTimeout(handler); }, [searchTerm, limitInput, authProvider]); - // 2. Hàm fetch data const fetchUsers = useCallback(async () => { setLoading(true); try { @@ -73,7 +81,15 @@ export default function UserTable() { } finally { setLoading(false); } - }, [page, debouncedParams, createdFrom, createdTo, isDeleted, sortBy, sortOrder]); + }, [ + page, + debouncedParams, + createdFrom, + createdTo, + isDeleted, + sortBy, + sortOrder, + ]); useEffect(() => { fetchUsers(); @@ -91,6 +107,42 @@ export default function UserTable() { const pagination = tableData?.pagination; + const handleOpenDetail = (user: fullDataUser) => { + setSelectedUser(user); + setIsModalOpen(true); + }; + const handleOpenRoleModal = (user: fullDataUser) => { + setRoleUser(user); + setIsRoleModalOpen(true); + }; + + const handleDelete = async (user: fullDataUser) => { + const confirmMessage = `Bạn có chắc chắn muốn khóa/xóa người dùng ${user.profile?.display_name || user.email}?`; + if (window.confirm(confirmMessage)) { + try { + await apiDeleteUser(user.id); + toast.success("Đã khóa người dùng thành công!"); + fetchUsers(); + } catch (err) { + console.error(err); + toast.error("Đã xảy ra lỗi khi xóa!"); + } + } + }; + + const handleRestore = async (user: fullDataUser) => { + const confirmMessage = `Bạn có chắc chắn muốn khôi phục người dùng ${user.profile?.display_name || user.email}?`; + if (window.confirm(confirmMessage)) { + try { + await apiRestoreUser(user.id); + toast.success("Khôi phục người dùng thành công!"); + fetchUsers(); + } catch (err) { + console.error(err); + toast.error("Đã xảy ra lỗi khi khôi phục!"); + } + } + }; return (
@@ -112,7 +164,9 @@ export default function UserTable() { {/* Auth Provider */}
- +
-
- - + setIsDeleted( + e.target.value === "" + ? undefined + : e.target.value === "true", + ) + } className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700" > @@ -137,7 +198,9 @@ export default function UserTable() { {/* Limit */}
- + )} -
- {/* Phân trang sử dụng data từ API mới */}

- Hiển thị trang {pagination?.current_page} / {pagination?.total_pages} ({pagination?.total_records} kết quả) + Hiển thị trang {pagination?.current_page} /{" "} + {pagination?.total_pages} ({pagination?.total_records} kết quả)

+ setIsModalOpen(false)} + user={selectedUser} + onChangeRole={handleOpenRoleModal} + onDelete={(u) => { + handleDelete(u); + setIsModalOpen(false); + }} + onRestore={(u) => { + handleRestore(u); + setIsModalOpen(false); + }} + /> + + setIsRoleModalOpen(false)} + user={roleUser} + onSuccess={fetchUsers} + />
); -} \ No newline at end of file +} diff --git a/src/app/(admin)/(others-pages)/profile/page.tsx b/src/app/(admin)/(others-pages)/profile/page.tsx index bbb803b..dd9b9a3 100644 --- a/src/app/(admin)/(others-pages)/profile/page.tsx +++ b/src/app/(admin)/(others-pages)/profile/page.tsx @@ -39,7 +39,7 @@ export default function Profile() {
- + {(mediaData?.data?.length ?? 0) > 0 && }
diff --git a/src/app/(full-width-pages)/(auth)/reset-password/page.tsx b/src/app/(full-width-pages)/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..edd8c89 --- /dev/null +++ b/src/app/(full-width-pages)/(auth)/reset-password/page.tsx @@ -0,0 +1,239 @@ +"use client"; +import Input from "@/components/form/input/InputField"; +import Label from "@/components/form/Label"; +import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "@/icons"; +import { apiCreateOTP, apiVerifyOTP, apiResetPassword } from "@/service/auth"; +import Link from "next/link"; +import { useState } from "react"; +import { toast } from "sonner"; + +export default function ResetPasswordForm() { + const [step, setStep] = useState(1); + const [email, setEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const [errorMsg, setErrorMsg] = useState(""); + const [loading, setLoading] = useState(false); + + const isValidEmail = (email: string) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + }; + + const isValidPassword = (pass: string) => { + const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/; + return passwordRegex.test(pass); + }; + + const handleSendOtp = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMsg(""); + + if (!email.trim()) { + setErrorMsg("Vui lòng nhập email."); + return; + } + if (!isValidEmail(email)) { + setErrorMsg("Email không đúng định dạng."); + return; + } + + try { + setLoading(true); + await apiCreateOTP(email); + toast.success("Mã OTP đã được gửi đến email của bạn!"); + setStep(2); + } catch (error) { + setErrorMsg("Lỗi khi gửi OTP. Vui lòng kiểm tra lại email."); + toast.error("Gửi OTP thất bại."); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async (e: React.FormEvent) => { + e.preventDefault(); + setErrorMsg(""); + + if (!otp.trim()) { + setErrorMsg("Vui lòng nhập mã OTP."); + return; + } + if (!isValidPassword(newPassword)) { + setErrorMsg("Mật khẩu chưa đủ điều kiện bảo mật."); + return; + } + + try { + setLoading(true); + + const verifyRes = await apiVerifyOTP(email, otp); + const tokenId = verifyRes?.data?.token_id; + + if (!tokenId) { + throw new Error("OTP không hợp lệ hoặc đã hết hạn."); + } + + const resetPayload = { + email: email, + new_password: newPassword, + token_id: tokenId, + }; + + await apiResetPassword(resetPayload); + + toast.success("Đổi mật khẩu thành công!"); + window.location.href = "/signin"; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Đổi mật khẩu thất bại."; + setErrorMsg(errorMessage); + console.error("Reset password error:", error); + toast.error("Vui lòng kiểm tra lại mã OTP."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + + Back to Sign In + +
+ +
+
+
+

+ {step === 1 ? "Forgot Password" : "Set New Password"} +

+

+ {step === 1 + ? "Enter your email address to receive an OTP code." + : `We sent an OTP to ${email}. Please enter it along with your new password.`} +

+
+ + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {step === 1 && ( +
+
+
+ + { + setEmail(e.target.value); + setErrorMsg(""); + }} + placeholder="Enter your registered email" + /> +
+ +
+ +
+
+
+ )} + + {step === 2 && ( +
+
+
+ + { + setOtp(e.target.value); + setErrorMsg(""); + }} + placeholder="Enter the 6-digit code" + /> +
+ +
+ +
0 && !isValidPassword(newPassword) ? "border border-red-500 ring-1 ring-red-500 rounded-lg" : ""}`} + > + { + setNewPassword(e.target.value); + setErrorMsg(""); + }} + placeholder="Min. 8 characters" + type={showPassword ? "text" : "password"} + /> + setShowPassword(!showPassword)} + className="absolute z-30 -translate-y-1/2 cursor-pointer right-4 top-1/2" + > + {showPassword ? : } + +
+ +

+ Mật khẩu phải chứa tối thiểu 8 ký tự, 1 chữ cái in hoa, 1 chữ số và 1 ký tự đặc biệt. +

+
+ +
+ + +
+
+
+ )} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index bdd6652..eeb7d38 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -750,3 +750,21 @@ span.flatpickr-weekday, opacity: 0.8; cursor: grabbing; /* Changes the cursor to indicate dragging */ } + +html { + scrollbar-gutter: stable; +} +html { + overflow-y: scroll; +} + +::-webkit-scrollbar { + -webkit-appearance: none; + width: 10px; +} + +::-webkit-scrollbar-thumb { + border-radius: 5px; + background-color: rgba(0, 0, 0, .5); + box-shadow: 0 0 1px rgba(255, 255, 255, .5); +} \ No newline at end of file diff --git a/src/components/tables/BasicTableOne.tsx b/src/components/tables/BasicTableOne.tsx index 422e84f..410d66d 100644 --- a/src/components/tables/BasicTableOne.tsx +++ b/src/components/tables/BasicTableOne.tsx @@ -11,23 +11,24 @@ import Badge from "../ui/badge/Badge"; import Image from "next/image"; import { fullDataUser } from "@/interface/admin"; -// Kiểu dữ liệu sort dựa trên UserTable type SortColumn = "created_at" | "updated_at" | "display_name" | "email"; interface BasicTableOneProps { data: fullDataUser[]; onSort: (column: SortColumn) => void; + onViewDetail: (user: fullDataUser) => void; sortBy?: SortColumn; sortOrder?: "asc" | "desc"; } export default function BasicTableOne({ - data, + data, onSort, + onViewDetail, sortBy, sortOrder, }: BasicTableOneProps) { - // Format ngày tháng: 08 Apr 2026 + const formatDate = (dateString: string) => { if (!dateString) return "-"; const date = new Date(dateString); @@ -38,7 +39,6 @@ export default function BasicTableOne({ }); }; - // Component icon sắp xếp const SortIcon = ({ column }: { column: SortColumn }) => { const isActive = sortBy === column; return ( @@ -78,7 +78,6 @@ export default function BasicTableOne({ return (
- {/* Đảm bảo bảng có chiều rộng tối thiểu để không bị nát layout trên mobile */}
@@ -146,6 +145,9 @@ export default function BasicTableOne({ + + Thao tác + @@ -156,7 +158,6 @@ export default function BasicTableOne({ key={user.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors" > - {/* Cột Tên + Avatar */}
@@ -185,12 +186,10 @@ export default function BasicTableOne({
- {/* Cột Email */} {user.email} - {/* Cột Roles (Badge list) */}
{user.roles && user.roles.length > 0 ? ( @@ -208,7 +207,6 @@ export default function BasicTableOne({
- {/* Cột Trạng thái (Sử dụng Badge component) */} - {/* Cột Ngày tham gia */} {formatDate(user.created_at)} - {/* Cột Ngày cập nhật */} {formatDate(user.updated_at)} + + + + )) ) : ( diff --git a/src/components/tables/ChangeRoleModal.tsx b/src/components/tables/ChangeRoleModal.tsx new file mode 100644 index 0000000..5b1f02b --- /dev/null +++ b/src/components/tables/ChangeRoleModal.tsx @@ -0,0 +1,141 @@ +"use client"; +import { useEffect, useState } from "react"; +import { Modal } from "../ui/modal"; +import Button from "../ui/button/Button"; +import { fullDataUser } from "@/interface/admin"; +import { apiGetAllRole, apiChangeRole } from "@/service/adminService"; +import { toast } from "sonner"; + +interface Role { + id: string; + name: string; +} + +interface ChangeRoleModalProps { + isOpen: boolean; + onClose: () => void; + user: fullDataUser | null; + onSuccess: () => void; +} + +export default function ChangeRoleModal({ isOpen, onClose, user, onSuccess }: ChangeRoleModalProps) { + const [roles, setRoles] = useState([]); + const [selectedRoleIds, setSelectedRoleIds] = useState([]); + const [loading, setLoading] = useState(false); + const [fetchingRoles, setFetchingRoles] = useState(true); + + useEffect(() => { + if (isOpen && user) { + setFetchingRoles(true); + apiGetAllRole() + .then((res) => { + if (res?.status) setRoles(res.data); + }) + .catch((err) => { + console.error("Lỗi fetch roles:", err); + toast.error("Không thể lấy danh sách vai trò"); + }) + .finally(() => setFetchingRoles(false)); + + const currentUserRoles = user.roles?.map((r) => r.id) || []; + setSelectedRoleIds(currentUserRoles); + } + }, [isOpen, user]); + + const handleToggleRole = (roleId: string) => { + setSelectedRoleIds((prev) => + prev.includes(roleId) ? prev.filter((id) => id !== roleId) : [...prev, roleId] + ); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!user) return; + + try { + setLoading(true); + const payload = { + role_ids: selectedRoleIds, + user_id: user.id, + }; + console.log("Payload gửi lên API:", payload); + await apiChangeRole(user.id, payload); + toast.success("Cập nhật vai trò thành công!"); + onSuccess(); + onClose(); + } catch (error) { + console.error(error); + toast.error("Lỗi khi cập nhật vai trò!"); + } finally { + setLoading(false); + } + }; + + if (!user) return null; + + return ( + +
+ +
+

+ Vai trò người dùng +

+

+ {user.profile?.display_name || user.email} +

+
+ +
+ {fetchingRoles ? ( +
+
+
+ ) : ( +
+ {roles.map((role) => { + const isSelected = selectedRoleIds.includes(role.id); + return ( + + ); + })} +
+ )} + +
+ + +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/tables/UserDetailModal.tsx b/src/components/tables/UserDetailModal.tsx new file mode 100644 index 0000000..ca81329 --- /dev/null +++ b/src/components/tables/UserDetailModal.tsx @@ -0,0 +1,127 @@ +"use client"; +import { Modal } from "../ui/modal"; +import UserMetaCard from "@/components/user-profile/UserMetaCard"; +import UserInfoCard from "@/components/user-profile/UserInfoCard"; +import { fullDataUser } from "@/interface/admin"; +import { useEffect, useState } from "react"; +import { MediaDto } from "@/interface/media"; +import { apiGetUserMedia } from "@/service/adminService"; +import MediaCard from "@/components/user-profile/Media"; + +interface UserDetailModalProps { + isOpen: boolean; + onClose: () => void; + user: fullDataUser | null; + onChangeRole: (user: fullDataUser) => void; + onDelete: (user: fullDataUser) => void; + onRestore: (user: fullDataUser) => void; +} + +export default function UserDetailModal({ + isOpen, + onClose, + user, + onChangeRole, + onDelete, + onRestore, +}: UserDetailModalProps) { + const [mediaData, setMediaData] = useState(null); + const [loading, setLoading] = useState(true); + + const formattedData = { data: user }; + + useEffect(() => { + if (user?.id && isOpen) { + const fetchUserMedia = async () => { + setLoading(true); + try { + const mediaResponse = await apiGetUserMedia(user.id); + setMediaData(mediaResponse); + } catch (err) { + console.error("Lỗi fetch media:", err); + setMediaData(null); + } finally { + setLoading(false); + } + }; + fetchUserMedia(); + } + }, [user?.id, isOpen]); + + if (!user) return null; + + return ( + +
+
+

+ Chi tiết người dùng +

+ +
+ +
+ + + +
+ {loading ? ( +
+
+

Đang tải tài liệu...

+
+ ) : ( + <> + {(mediaData?.data?.length ?? 0) > 0 ? ( + + ) : ( +
+ Người dùng này chưa có dữ liệu media. +
+ )} + + )} +
+
+ +
+
+ Thao tác quản trị viên +
+
+ + + {user.is_deleted ? ( + + ) : ( + + )} +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/modal/index.tsx b/src/components/ui/modal/index.tsx index 7048e38..8e49885 100644 --- a/src/components/ui/modal/index.tsx +++ b/src/components/ui/modal/index.tsx @@ -6,8 +6,8 @@ interface ModalProps { onClose: () => void; className?: string; children: React.ReactNode; - showCloseButton?: boolean; // New prop to control close button visibility - isFullscreen?: boolean; // Default to false for backwards compatibility + showCloseButton?: boolean; + isFullscreen?: boolean; } export const Modal: React.FC = ({ @@ -15,7 +15,7 @@ export const Modal: React.FC = ({ onClose, children, className, - showCloseButton = true, // Default to true for backwards compatibility + showCloseButton = true, isFullscreen = false, }) => { const modalRef = useRef(null); @@ -52,13 +52,13 @@ export const Modal: React.FC = ({ const contentClasses = isFullscreen ? "w-full h-full" - : "relative w-full rounded-3xl bg-white dark:bg-gray-900"; + : "relative w-full rounded-xl bg-white dark:bg-gray-900"; return (
{!isFullscreen && (
)} diff --git a/src/components/user-profile/UserInfoCard.tsx b/src/components/user-profile/UserInfoCard.tsx index 910876f..f98d51a 100644 --- a/src/components/user-profile/UserInfoCard.tsx +++ b/src/components/user-profile/UserInfoCard.tsx @@ -95,7 +95,7 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) { Phone

- {data.data?.profile?.phone || "+123 456 7890"} + {data.data?.profile?.phone || "+XXX XXX XXX"}

@@ -132,26 +132,27 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
- + + + + Edit + + )} diff --git a/src/interface/user.ts b/src/interface/user.ts index 2754051..a471059 100644 --- a/src/interface/user.ts +++ b/src/interface/user.ts @@ -50,4 +50,5 @@ export interface UserMetaCardProps { message?: string; status?: boolean; data?: Data; + openEdit?: boolean; } \ No newline at end of file diff --git a/src/service/adminService.ts b/src/service/adminService.ts index 918f914..702f13f 100644 --- a/src/service/adminService.ts +++ b/src/service/adminService.ts @@ -3,8 +3,31 @@ import { API } from "../../api"; import { getUserDto } from "@/interface/admin"; export const apiGetListUser = async (payload: getUserDto) => { - const response = await api.get(API.Admin.GET_LIST_USERS, { + const response = await api.get(API.Admin.GET_LIST_USERS, { params: payload, }); return response?.data; }; + +export const apiChangeRole = async (id: string, payload: any) => { + const response = await api.patch(API.Admin.CHANGE_ROLE(id), payload); + console.log("Response từ API sau khi đổi role:", response); + return response?.data; +}; +export const apiDeleteUser = async (id: string) => { + const response = await api.delete(API.Admin.DELETE_USER(id)); + return response?.data; +}; +export const apiRestoreUser = async (id: string) => { + const response = await api.patch(API.Admin.RESTORE_USER(id)); + return response?.data; +}; +export const apiGetAllRole = async () => { + const response = await api.get(API.Admin.GET_ALL_ROLE); + return response?.data; +}; + +export const apiGetUserMedia = async (id: string) => { + const response = await api.get(API.Admin.GET_USER_MEDIA(id)); + return response?.data; +}; diff --git a/src/service/auth.ts b/src/service/auth.ts index 7e51ca9..94d09fa 100644 --- a/src/service/auth.ts +++ b/src/service/auth.ts @@ -31,6 +31,11 @@ export const apiSignIn = async (payload: any) => { return response.data; }; +export const apiResetPassword = async (payload: any) => { + const response = await api.post(API.Auth.FORGOT_PASSWORD, payload); + return response.data; +}; + export const apiGetCurrentUser = async () => { const response = await api.get(API.User.CURRENT); return response?.data;