feat(reset-password): implement reset password functionality for admins and customers
- Added new ResetPasswordModalContent component for displaying generated passwords. - Integrated reset password functionality in the AdminsTable and CustomersTable components, allowing users to reset passwords for admins and customers. - Created useResetPasswordModalPresenter hook to manage password visibility and clipboard copying. - Updated API services and hooks to support reset password requests for both admins and customers. - Enhanced tooltip functionality for reset password actions in the tables.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons";
|
||||
import {
|
||||
KeyIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
UserSettingIcon,
|
||||
} from "@/assets/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
import IsVisible from "@/components/ui/IsVisible";
|
||||
@@ -8,6 +13,7 @@ import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import ResetPasswordModalContent from "@/components/ui/ResetPasswordModalContent";
|
||||
import Status from "@/components/ui/Status";
|
||||
import {
|
||||
Table,
|
||||
@@ -56,6 +62,11 @@ const AdminsTable = () => {
|
||||
navigateToEditAdmin,
|
||||
openDeleteModalForUser,
|
||||
openChangeStatusModalForUser,
|
||||
isResetPasswordModalOpen,
|
||||
closeResetPasswordModal,
|
||||
openResetPasswordModalForUser,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
} = useAdminsPresenter();
|
||||
|
||||
return (
|
||||
@@ -153,6 +164,23 @@ const AdminsTable = () => {
|
||||
<span className="sr-only">Change Admin Status</span>
|
||||
<UserSettingIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="reset-password"
|
||||
data-tooltip-content="Reset password"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-primary"
|
||||
data-cy={`admins-row-reset-password-${item?.id ?? index}`}
|
||||
onClick={() => openResetPasswordModalForUser(item)}
|
||||
>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({
|
||||
id: "reset-password",
|
||||
})}
|
||||
/>
|
||||
<span className="sr-only">Reset Admin Password</span>
|
||||
<KeyIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="edit"
|
||||
@@ -190,6 +218,20 @@ const AdminsTable = () => {
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={isResetPasswordModalOpen}
|
||||
onClose={closeResetPasswordModal}
|
||||
showButtons={false}
|
||||
>
|
||||
<ResetPasswordModalContent
|
||||
subjectName={currentUser?.full_name}
|
||||
subjectLabel="admin"
|
||||
password={generatedPassword}
|
||||
isLoading={isResettingPassword}
|
||||
isOpen={isResetPasswordModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useChangeAdminStatus,
|
||||
useDeleteAdmin,
|
||||
useGetAdminListQuery,
|
||||
useResetAdminPassword,
|
||||
} from "@/hooks/queries";
|
||||
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
@@ -27,6 +28,11 @@ export const useAdminsPresenter = () => {
|
||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isResetPasswordModalOpen, setIsResetPasswordModalOpen] =
|
||||
useState(false);
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [status, setStatus] = useState<string>(
|
||||
currentUser?.status ?? AdminStatus.ACTIVE,
|
||||
);
|
||||
@@ -115,6 +121,26 @@ export const useAdminsPresenter = () => {
|
||||
setIsChangeStatusModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const { mutate: resetAdminPassword, isPending: isResettingPassword } =
|
||||
useResetAdminPassword({
|
||||
successCallback: (password) => setGeneratedPassword(password),
|
||||
});
|
||||
|
||||
const openResetPasswordModalForUser = useCallback(
|
||||
(user: IUser) => {
|
||||
setCurrentUser(user);
|
||||
setGeneratedPassword(null);
|
||||
setIsResetPasswordModalOpen(true);
|
||||
resetAdminPassword(user.id);
|
||||
},
|
||||
[resetAdminPassword],
|
||||
);
|
||||
|
||||
const closeResetPasswordModal = useCallback(() => {
|
||||
setIsResetPasswordModalOpen(false);
|
||||
setGeneratedPassword(null);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
@@ -237,5 +263,10 @@ export const useAdminsPresenter = () => {
|
||||
navigateToEditAdmin,
|
||||
openDeleteModalForUser,
|
||||
openChangeStatusModalForUser,
|
||||
isResetPasswordModalOpen,
|
||||
closeResetPasswordModal,
|
||||
openResetPasswordModalForUser,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
import { ExclamationIcon, PencilSquareIcon, TrashIcon } from "@/assets/icons";
|
||||
import {
|
||||
ExclamationIcon,
|
||||
KeyIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@/assets/icons";
|
||||
import { Building } from "@/components/Layouts/sidebar/icons";
|
||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||
@@ -8,6 +13,7 @@ import ListHeader from "@/components/ui/ListHeader";
|
||||
import ListWrapper from "@/components/ui/ListWrapper";
|
||||
import Modal from "@/components/ui/modal";
|
||||
import Pagination from "@/components/ui/pagination";
|
||||
import ResetPasswordModalContent from "@/components/ui/ResetPasswordModalContent";
|
||||
import Status from "@/components/ui/Status";
|
||||
import {
|
||||
Table,
|
||||
@@ -58,6 +64,11 @@ const CustomersTable = () => {
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
isResetPasswordModalOpen,
|
||||
closeResetPasswordModal,
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
} = useCustomerListPresenter();
|
||||
|
||||
return (
|
||||
@@ -215,6 +226,26 @@ const CustomersTable = () => {
|
||||
<span className="sr-only">Assign To Company </span>
|
||||
<Building />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip-id="reset-password"
|
||||
data-tooltip-content="Reset password"
|
||||
data-tooltip-place="top"
|
||||
className="hover:text-primary"
|
||||
onClick={() =>
|
||||
openResetPasswordModalForCustomer(customer)
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
{..._TooltipDefaultParams({
|
||||
id: "reset-password",
|
||||
})}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Reset Customer Password
|
||||
</span>
|
||||
<KeyIcon />
|
||||
</button>
|
||||
<button
|
||||
data-tooltip-id="feedback"
|
||||
data-tooltip-content="Feedback"
|
||||
@@ -269,6 +300,19 @@ const CustomersTable = () => {
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={isResetPasswordModalOpen}
|
||||
onClose={closeResetPasswordModal}
|
||||
showButtons={false}
|
||||
>
|
||||
<ResetPasswordModalContent
|
||||
subjectName={currentCustomer?.full_name}
|
||||
subjectLabel="customer"
|
||||
password={generatedPassword}
|
||||
isLoading={isResettingPassword}
|
||||
isOpen={isResetPasswordModalOpen}
|
||||
/>
|
||||
</Modal>
|
||||
{isModalOpen && (
|
||||
<ConfirmationModal
|
||||
isOpen={isModalOpen}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCompanyFullList,
|
||||
useCustomersQuery,
|
||||
useDeleteCustomerQuery,
|
||||
useResetCustomerPassword,
|
||||
} from "@/hooks/queries";
|
||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||
@@ -27,6 +28,11 @@ const useCustomerListPresenter = () => {
|
||||
);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isResetPasswordModalOpen, setIsResetPasswordModalOpen] =
|
||||
useState(false);
|
||||
const [generatedPassword, setGeneratedPassword] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const pathName = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [params, setParams] = useState<Record<string, any>>({
|
||||
@@ -90,6 +96,26 @@ const useCustomerListPresenter = () => {
|
||||
},
|
||||
);
|
||||
|
||||
const { mutate: resetCustomerPassword, isPending: isResettingPassword } =
|
||||
useResetCustomerPassword({
|
||||
successCallback: (password) => setGeneratedPassword(password),
|
||||
});
|
||||
|
||||
const openResetPasswordModalForCustomer = useCallback(
|
||||
(customer: TCustomer) => {
|
||||
setCurrentCustomer(customer);
|
||||
setGeneratedPassword(null);
|
||||
setIsResetPasswordModalOpen(true);
|
||||
resetCustomerPassword(customer.id);
|
||||
},
|
||||
[resetCustomerPassword],
|
||||
);
|
||||
|
||||
const closeResetPasswordModal = useCallback(() => {
|
||||
setIsResetPasswordModalOpen(false);
|
||||
setGeneratedPassword(null);
|
||||
}, []);
|
||||
|
||||
const search = (formData: any) => {
|
||||
const newParams = buildFirstPageParams({ ...params, ...formData });
|
||||
const cleanedParams = deleteEmptyKeys(newParams);
|
||||
@@ -154,6 +180,11 @@ const useCustomerListPresenter = () => {
|
||||
dataTbodyRef,
|
||||
pagination,
|
||||
handlePaginationChange,
|
||||
isResetPasswordModalOpen,
|
||||
closeResetPasswordModal,
|
||||
openResetPasswordModalForCustomer,
|
||||
generatedPassword,
|
||||
isResettingPassword,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ const parseCpvCodesFromSearchParams = (
|
||||
|
||||
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
||||
...params,
|
||||
documents_scraped:
|
||||
params.documents_scraped === true || params.documents_scraped === "true",
|
||||
cpv_codes: formatCpvCodesForForm(params.cpv_codes),
|
||||
created_at_range: buildRangeFormValue(
|
||||
params,
|
||||
@@ -157,6 +159,7 @@ const TENDER_FILTER_FORM_DEFAULTS: Record<string, any> = {
|
||||
main_classification: "",
|
||||
classifications: "",
|
||||
cpv_codes: "",
|
||||
documents_scraped: false,
|
||||
created_at_range: [],
|
||||
tender_deadline_range: [],
|
||||
publication_date_range: [],
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon, EyeIcon, KeyIcon } from "@/assets/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FC } from "react";
|
||||
import IsVisible from "./IsVisible";
|
||||
import { useResetPasswordModalPresenter } from "./useResetPasswordModalPresenter";
|
||||
|
||||
interface IProps {
|
||||
subjectName?: string | null;
|
||||
subjectLabel?: string;
|
||||
password: string | null;
|
||||
isLoading: boolean;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const ResetPasswordModalContent: FC<IProps> = ({
|
||||
subjectName,
|
||||
subjectLabel = "user",
|
||||
password,
|
||||
isLoading,
|
||||
isOpen,
|
||||
}) => {
|
||||
const {
|
||||
isCopied,
|
||||
isPasswordVisible,
|
||||
togglePasswordVisibility,
|
||||
copyPasswordToClipboard,
|
||||
} = useResetPasswordModalPresenter({ isOpen, password });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-md sm:min-w-[24rem]"
|
||||
data-cy="reset-password-modal-content"
|
||||
>
|
||||
<div className="relative mb-5 flex flex-col items-center text-center">
|
||||
<div className="pointer-events-none absolute -top-6 h-24 w-24 rounded-full bg-primary/25 blur-3xl" />
|
||||
<div className="relative flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-primary/15 via-primary/10 to-primary/5 ring-1 ring-inset ring-primary/20 shadow-inner">
|
||||
<KeyIcon className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold tracking-tight text-dark dark:text-white">
|
||||
Reset Password
|
||||
</h3>
|
||||
<p className="mt-1 max-w-xs text-sm leading-relaxed text-dark-5 dark:text-dark-6">
|
||||
<IsVisible condition={!!subjectName}>
|
||||
A new password has been generated for{" "}
|
||||
<span className="font-medium text-dark dark:text-white">
|
||||
{subjectName}
|
||||
</span>
|
||||
. Copy it now — it will not be shown again.
|
||||
</IsVisible>
|
||||
<IsVisible condition={!subjectName}>
|
||||
Generating a new password for this {subjectLabel}. Copy it now — it
|
||||
will not be shown again.
|
||||
</IsVisible>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<IsVisible condition={isLoading}>
|
||||
<div
|
||||
className="flex h-24 items-center justify-center rounded-xl bg-gray-1/60 ring-1 ring-inset ring-stroke dark:bg-dark/40 dark:ring-stroke-dark"
|
||||
data-cy="reset-password-loading"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-dark-5 dark:text-dark-6">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary/30 border-t-primary" />
|
||||
Generating a secure password…
|
||||
</span>
|
||||
</div>
|
||||
</IsVisible>
|
||||
|
||||
<IsVisible condition={!isLoading && !!password}>
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-gray-1 via-white to-gray-1 p-4 ring-1 ring-inset ring-stroke shadow-sm dark:from-dark/40 dark:via-dark-2 dark:to-dark/40 dark:ring-stroke-dark"
|
||||
data-cy="reset-password-result"
|
||||
>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-dark-5 dark:text-dark-6">
|
||||
New password
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code
|
||||
className="flex-1 select-all rounded-lg bg-white px-3 py-2.5 font-mono text-sm font-semibold tracking-wide text-dark shadow-inner ring-1 ring-inset ring-stroke break-all dark:bg-dark-3 dark:text-white dark:ring-stroke-dark"
|
||||
data-cy="reset-password-value"
|
||||
>
|
||||
{isPasswordVisible
|
||||
? password
|
||||
: "•".repeat(Math.max(password?.length ?? 0, 8))}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-white text-dark-5 ring-1 ring-inset ring-stroke transition hover:-translate-y-0.5 hover:text-primary dark:bg-dark-3 dark:text-dark-6 dark:ring-stroke-dark dark:hover:text-primary"
|
||||
data-cy="reset-password-toggle-visibility"
|
||||
aria-label={
|
||||
isPasswordVisible ? "Hide password" : "Show password"
|
||||
}
|
||||
>
|
||||
<EyeIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyPasswordToClipboard}
|
||||
disabled={isCopied}
|
||||
className={cn(
|
||||
"mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold transition-all duration-200 hover:-translate-y-0.5 hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-dark-2",
|
||||
isCopied
|
||||
? "bg-gradient-to-br from-green to-green-dark text-white shadow-[0_6px_18px_-4px_rgba(34,173,92,0.55)] focus-visible:ring-green/40"
|
||||
: "bg-gradient-to-br from-primary to-[#4C46C9] text-white shadow-[0_6px_18px_-4px_rgba(87,80,241,0.55)] focus-visible:ring-primary/40",
|
||||
)}
|
||||
data-cy="reset-password-copy-button"
|
||||
>
|
||||
<IsVisible condition={isCopied}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
<span>Copied</span>
|
||||
</IsVisible>
|
||||
<IsVisible condition={!isCopied}>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<span>Copy to clipboard</span>
|
||||
</IsVisible>
|
||||
</button>
|
||||
</div>
|
||||
</IsVisible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordModalContent;
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
const COPY_FEEDBACK_TIMEOUT_MS = 2000;
|
||||
|
||||
export const useResetPasswordModalPresenter = ({
|
||||
isOpen,
|
||||
password,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
password: string | null;
|
||||
}) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsCopied(false);
|
||||
setIsPasswordVisible(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const togglePasswordVisibility = useCallback(() => {
|
||||
setIsPasswordVisible((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const copyPasswordToClipboard = useCallback(async () => {
|
||||
if (!password) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
setIsCopied(true);
|
||||
toast.success("Password copied to clipboard");
|
||||
setTimeout(() => setIsCopied(false), COPY_FEEDBACK_TIMEOUT_MS);
|
||||
} catch {
|
||||
toast.error("Failed to copy password");
|
||||
}
|
||||
}, [password]);
|
||||
|
||||
return {
|
||||
isCopied,
|
||||
isPasswordVisible,
|
||||
togglePasswordVisibility,
|
||||
copyPasswordToClipboard,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user