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:
@@ -0,0 +1,24 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Engineering Guidelines & Rules
|
||||||
|
|
||||||
|
## 1. Architecture & Component Structure
|
||||||
|
|
||||||
|
- **Strict Presenter/Container Pattern:** `.tsx` files must act strictly as view components containing _only_ JSX/TSX elements and styling markup.
|
||||||
|
- **Decoupled Logic:** Absolutely no business logic, state management, or side effects are allowed directly inside a `.tsx` file.
|
||||||
|
- **Hook-Based Presenters:** All logic, event handlers, and data fetching must live in a custom React hook (Presenter) located in a sibling file directly next to the `.tsx` file (e.g., `CreateAdmin.tsx` paired with `useCreateAdminPresenter.ts`).
|
||||||
|
- **Compound Components Pattern:** When building complex or configurable UI components (like forms, dropdowns, tables, or tabs), prefer the Compound Components pattern. Provide a main container component and context-connected sub-components (e.g., `<Form>`, `<Form.Input>`, `<Form.Submit>`) to allow flexible layout assembly while keeping the logic encapsulated.
|
||||||
|
|
||||||
|
## 2. Code Quality & DRY Principle
|
||||||
|
|
||||||
|
- **Zero Duplication:** Strict adherence to the DRY (Don't Repeat Yourself) principle.
|
||||||
|
- **Refactoring First:** If a piece of logic, utility, configuration, or UI structure is needed in more than one place, it must be extracted into a reusable helper, hook, or shared component. Do not allow even a single instance of copy-pasted or identical code structures.
|
||||||
|
|
||||||
|
## 3. Software Engineering Design
|
||||||
|
|
||||||
|
- **SOLID Principles:** All code written must strictly adhere to SOLID design principles:
|
||||||
|
- **Single Responsibility (SRP):** Each class, hook, or component must have exactly one reason to change (e.g., views only render, presenters only manage state/logic).
|
||||||
|
- **Open/Closed (OCP):** Software entities should be open for extension, but closed for modification.
|
||||||
|
- **Liskov Substitution (LSP):** Subtypes must be completely substitutable for their base types.
|
||||||
|
- **Interface Segregation (ISP):** Clients should not be forced to depend on interfaces they do not use. Keep hooks and component props lean and focused.
|
||||||
|
- **Dependency Inversion (DIP):** Depend on abstractions, not concretions. Pass dependencies (like API clients or services) into hooks or use context where appropriate rather than hardcoding them.
|
||||||
@@ -600,6 +600,57 @@ export function PencilSquareIcon(props: IconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function KeyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M15.5 11.5C18.5376 11.5 21 9.03757 21 6C21 2.96243 18.5376 0.5 15.5 0.5C12.4624 0.5 10 2.96243 10 6C10 6.79363 10.168 7.5481 10.4705 8.22988L0.5 18.2003V23.5H5.79972L7.40195 21.8978L7.40123 19.7032L9.59639 19.7039L9.59707 17.5093L11.792 17.5093L15.2701 14.0295C15.9519 14.332 16.7064 14.5 17.5 14.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="16.75" cy="5" r="1.25" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="8"
|
||||||
|
y="8"
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
rx="2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 8V5.5C16 4.39543 15.1046 3.5 14 3.5H5.5C4.39543 3.5 3.5 4.39543 3.5 5.5V14C3.5 15.1046 4.39543 16 5.5 16H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadIcon(props: IconProps) {
|
export function UploadIcon(props: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { PencilSquareIcon, TrashIcon, UserSettingIcon } from "@/assets/icons";
|
import {
|
||||||
|
KeyIcon,
|
||||||
|
PencilSquareIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UserSettingIcon,
|
||||||
|
} from "@/assets/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
import IsVisible from "@/components/ui/IsVisible";
|
import IsVisible from "@/components/ui/IsVisible";
|
||||||
@@ -8,6 +13,7 @@ import ListHeader from "@/components/ui/ListHeader";
|
|||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
|
import ResetPasswordModalContent from "@/components/ui/ResetPasswordModalContent";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -56,6 +62,11 @@ const AdminsTable = () => {
|
|||||||
navigateToEditAdmin,
|
navigateToEditAdmin,
|
||||||
openDeleteModalForUser,
|
openDeleteModalForUser,
|
||||||
openChangeStatusModalForUser,
|
openChangeStatusModalForUser,
|
||||||
|
isResetPasswordModalOpen,
|
||||||
|
closeResetPasswordModal,
|
||||||
|
openResetPasswordModalForUser,
|
||||||
|
generatedPassword,
|
||||||
|
isResettingPassword,
|
||||||
} = useAdminsPresenter();
|
} = useAdminsPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -153,6 +164,23 @@ const AdminsTable = () => {
|
|||||||
<span className="sr-only">Change Admin Status</span>
|
<span className="sr-only">Change Admin Status</span>
|
||||||
<UserSettingIcon />
|
<UserSettingIcon />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tooltip-id="edit"
|
data-tooltip-id="edit"
|
||||||
@@ -190,6 +218,20 @@ const AdminsTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isResetPasswordModalOpen}
|
||||||
|
onClose={closeResetPasswordModal}
|
||||||
|
showButtons={false}
|
||||||
|
>
|
||||||
|
<ResetPasswordModalContent
|
||||||
|
subjectName={currentUser?.full_name}
|
||||||
|
subjectLabel="admin"
|
||||||
|
password={generatedPassword}
|
||||||
|
isLoading={isResettingPassword}
|
||||||
|
isOpen={isResetPasswordModalOpen}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useChangeAdminStatus,
|
useChangeAdminStatus,
|
||||||
useDeleteAdmin,
|
useDeleteAdmin,
|
||||||
useGetAdminListQuery,
|
useGetAdminListQuery,
|
||||||
|
useResetAdminPassword,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
import { IUser, TChangeAdminStatusCredentials } from "@/lib/api";
|
||||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||||
@@ -27,6 +28,11 @@ export const useAdminsPresenter = () => {
|
|||||||
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
const [isChangeStatusModalOpen, setIsChangeStatusModalOpen] = useState(false);
|
||||||
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
const [currentUser, setCurrentUser] = useState<IUser | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isResetPasswordModalOpen, setIsResetPasswordModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [generatedPassword, setGeneratedPassword] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [status, setStatus] = useState<string>(
|
const [status, setStatus] = useState<string>(
|
||||||
currentUser?.status ?? AdminStatus.ACTIVE,
|
currentUser?.status ?? AdminStatus.ACTIVE,
|
||||||
);
|
);
|
||||||
@@ -115,6 +121,26 @@ export const useAdminsPresenter = () => {
|
|||||||
setIsChangeStatusModalOpen(true);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const reduced =
|
const reduced =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -237,5 +263,10 @@ export const useAdminsPresenter = () => {
|
|||||||
navigateToEditAdmin,
|
navigateToEditAdmin,
|
||||||
openDeleteModalForUser,
|
openDeleteModalForUser,
|
||||||
openChangeStatusModalForUser,
|
openChangeStatusModalForUser,
|
||||||
|
isResetPasswordModalOpen,
|
||||||
|
closeResetPasswordModal,
|
||||||
|
openResetPasswordModalForUser,
|
||||||
|
generatedPassword,
|
||||||
|
isResettingPassword,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"use client";
|
"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 { Building } from "@/components/Layouts/sidebar/icons";
|
||||||
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
import EmptyListWrapper from "@/components/HOC/EmptyListWrapper";
|
||||||
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
import ConfirmationModal from "@/components/ui/ConfirmationModal";
|
||||||
@@ -8,6 +13,7 @@ import ListHeader from "@/components/ui/ListHeader";
|
|||||||
import ListWrapper from "@/components/ui/ListWrapper";
|
import ListWrapper from "@/components/ui/ListWrapper";
|
||||||
import Modal from "@/components/ui/modal";
|
import Modal from "@/components/ui/modal";
|
||||||
import Pagination from "@/components/ui/pagination";
|
import Pagination from "@/components/ui/pagination";
|
||||||
|
import ResetPasswordModalContent from "@/components/ui/ResetPasswordModalContent";
|
||||||
import Status from "@/components/ui/Status";
|
import Status from "@/components/ui/Status";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -58,6 +64,11 @@ const CustomersTable = () => {
|
|||||||
dataTbodyRef,
|
dataTbodyRef,
|
||||||
pagination,
|
pagination,
|
||||||
handlePaginationChange,
|
handlePaginationChange,
|
||||||
|
isResetPasswordModalOpen,
|
||||||
|
closeResetPasswordModal,
|
||||||
|
openResetPasswordModalForCustomer,
|
||||||
|
generatedPassword,
|
||||||
|
isResettingPassword,
|
||||||
} = useCustomerListPresenter();
|
} = useCustomerListPresenter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -215,6 +226,26 @@ const CustomersTable = () => {
|
|||||||
<span className="sr-only">Assign To Company </span>
|
<span className="sr-only">Assign To Company </span>
|
||||||
<Building />
|
<Building />
|
||||||
</button>
|
</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
|
<button
|
||||||
data-tooltip-id="feedback"
|
data-tooltip-id="feedback"
|
||||||
data-tooltip-content="Feedback"
|
data-tooltip-content="Feedback"
|
||||||
@@ -269,6 +300,19 @@ const CustomersTable = () => {
|
|||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
<Modal
|
||||||
|
isOpen={isResetPasswordModalOpen}
|
||||||
|
onClose={closeResetPasswordModal}
|
||||||
|
showButtons={false}
|
||||||
|
>
|
||||||
|
<ResetPasswordModalContent
|
||||||
|
subjectName={currentCustomer?.full_name}
|
||||||
|
subjectLabel="customer"
|
||||||
|
password={generatedPassword}
|
||||||
|
isLoading={isResettingPassword}
|
||||||
|
isOpen={isResetPasswordModalOpen}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useCompanyFullList,
|
useCompanyFullList,
|
||||||
useCustomersQuery,
|
useCustomersQuery,
|
||||||
useDeleteCustomerQuery,
|
useDeleteCustomerQuery,
|
||||||
|
useResetCustomerPassword,
|
||||||
} from "@/hooks/queries";
|
} from "@/hooks/queries";
|
||||||
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
import { useHybridPagination } from "@/hooks/useHybridPagination";
|
||||||
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
import { useTableSectionRowAnimations } from "@/hooks/useTableSectionRowAnimations";
|
||||||
@@ -27,6 +28,11 @@ const useCustomerListPresenter = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isResetPasswordModalOpen, setIsResetPasswordModalOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [generatedPassword, setGeneratedPassword] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [params, setParams] = useState<Record<string, any>>({
|
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 search = (formData: any) => {
|
||||||
const newParams = buildFirstPageParams({ ...params, ...formData });
|
const newParams = buildFirstPageParams({ ...params, ...formData });
|
||||||
const cleanedParams = deleteEmptyKeys(newParams);
|
const cleanedParams = deleteEmptyKeys(newParams);
|
||||||
@@ -154,6 +180,11 @@ const useCustomerListPresenter = () => {
|
|||||||
dataTbodyRef,
|
dataTbodyRef,
|
||||||
pagination,
|
pagination,
|
||||||
handlePaginationChange,
|
handlePaginationChange,
|
||||||
|
isResetPasswordModalOpen,
|
||||||
|
closeResetPasswordModal,
|
||||||
|
openResetPasswordModalForCustomer,
|
||||||
|
generatedPassword,
|
||||||
|
isResettingPassword,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ const parseCpvCodesFromSearchParams = (
|
|||||||
|
|
||||||
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
const buildTenderFilterFormValues = (params: Record<string, any>) => ({
|
||||||
...params,
|
...params,
|
||||||
|
documents_scraped:
|
||||||
|
params.documents_scraped === true || params.documents_scraped === "true",
|
||||||
cpv_codes: formatCpvCodesForForm(params.cpv_codes),
|
cpv_codes: formatCpvCodesForForm(params.cpv_codes),
|
||||||
created_at_range: buildRangeFormValue(
|
created_at_range: buildRangeFormValue(
|
||||||
params,
|
params,
|
||||||
@@ -157,6 +159,7 @@ const TENDER_FILTER_FORM_DEFAULTS: Record<string, any> = {
|
|||||||
main_classification: "",
|
main_classification: "",
|
||||||
classifications: "",
|
classifications: "",
|
||||||
cpv_codes: "",
|
cpv_codes: "",
|
||||||
|
documents_scraped: false,
|
||||||
created_at_range: [],
|
created_at_range: [],
|
||||||
tender_deadline_range: [],
|
tender_deadline_range: [],
|
||||||
publication_date_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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,42 +13,42 @@ const variantPalettes: Record<string, Palette> = {
|
|||||||
from: "#F23030",
|
from: "#F23030",
|
||||||
to: "#B00B0B",
|
to: "#B00B0B",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(242,48,48,0.55), inset 0 1px 0 rgba(255,255,255,0.18)",
|
"0 0 24px -4px rgba(242,48,48,0.55), inset 0 1px 0 rgba(255,255,255,0.18)",
|
||||||
border: "rgba(255,255,255,0.14)",
|
border: "rgba(255,255,255,0.14)",
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
from: "#5750F1",
|
from: "#5750F1",
|
||||||
to: "#3B36B3",
|
to: "#3B36B3",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(87,80,241,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
"0 0 24px -4px rgba(87,80,241,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||||
border: "rgba(255,255,255,0.16)",
|
border: "rgba(255,255,255,0.16)",
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
from: "#22AD5C",
|
from: "#22AD5C",
|
||||||
to: "#147F3D",
|
to: "#147F3D",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(34,173,92,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
"0 0 24px -4px rgba(34,173,92,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||||
border: "rgba(255,255,255,0.16)",
|
border: "rgba(255,255,255,0.16)",
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
from: "#FBB040",
|
from: "#FBB040",
|
||||||
to: "#D97706",
|
to: "#D97706",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(217,119,6,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
"0 0 24px -4px rgba(217,119,6,0.55), inset 0 1px 0 rgba(255,255,255,0.2)",
|
||||||
border: "rgba(255,255,255,0.16)",
|
border: "rgba(255,255,255,0.16)",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
from: "#1F2937",
|
from: "#1F2937",
|
||||||
to: "#0B1220",
|
to: "#0B1220",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.08)",
|
"0 0 24px -4px rgba(0,0,0,0.65), inset 0 1px 0 rgba(255,255,255,0.08)",
|
||||||
border: "rgba(255,255,255,0.08)",
|
border: "rgba(255,255,255,0.08)",
|
||||||
},
|
},
|
||||||
light: {
|
light: {
|
||||||
from: "#FFFFFF",
|
from: "#FFFFFF",
|
||||||
to: "#F1F5F9",
|
to: "#F1F5F9",
|
||||||
shadow:
|
shadow:
|
||||||
"0 10px 30px -10px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.6)",
|
"0 0 24px -4px rgba(0,0,0,0.18), inset 0 1px 0 rgba(255,255,255,0.6)",
|
||||||
border: "rgba(15,23,42,0.06)",
|
border: "rgba(15,23,42,0.06)",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,6 +129,25 @@ export const useUpdateCustomer = (id: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useResetCustomerPassword = ({
|
||||||
|
successCallback,
|
||||||
|
}: {
|
||||||
|
successCallback?: (password: string) => void;
|
||||||
|
} = {}) => {
|
||||||
|
const mutationKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.CUSTOMERS.READ_ALL, "reset-password"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useMutation({
|
||||||
|
mutationKey,
|
||||||
|
mutationFn: customersService.resetCustomerPassword,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success(response.message);
|
||||||
|
successCallback?.(response.data.password);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useDeleteCustomerQuery = (successCallback?: () => void) => {
|
export const useDeleteCustomerQuery = (successCallback?: () => void) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutationKey = useMemo(
|
const mutationKey = useMemo(
|
||||||
|
|||||||
@@ -135,6 +135,25 @@ export const useGetUsersQuery = (params?: Record<string, any>) => {
|
|||||||
queryFn: () => userService.adminsList(params),
|
queryFn: () => userService.adminsList(params),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
export const useResetAdminPassword = ({
|
||||||
|
successCallback,
|
||||||
|
}: {
|
||||||
|
successCallback?: (password: string) => void;
|
||||||
|
} = {}) => {
|
||||||
|
const mutationKey = useMemo(
|
||||||
|
() => [API_ENDPOINTS.USER.ADMINS, "reset-password"],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
return useMutation({
|
||||||
|
mutationKey,
|
||||||
|
mutationFn: userService.resetAdminPassword,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
toast.success(response.message);
|
||||||
|
successCallback?.(response.data.password);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useChangeAdminStatus = ({
|
export const useChangeAdminStatus = ({
|
||||||
id,
|
id,
|
||||||
successCallback,
|
successCallback,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const API_ENDPOINTS = {
|
|||||||
ADMINS: "users",
|
ADMINS: "users",
|
||||||
ADMIN_DETAILS: (id: string) => `users/${id}`,
|
ADMIN_DETAILS: (id: string) => `users/${id}`,
|
||||||
CHANGE_ADMIN_STATUS: (id: string) => `users/${id}/status`,
|
CHANGE_ADMIN_STATUS: (id: string) => `users/${id}/status`,
|
||||||
|
RESET_ADMIN_PASSWORD: (id: string) => `users/${id}/reset-password`,
|
||||||
},
|
},
|
||||||
FILES: {
|
FILES: {
|
||||||
UPLOAD: "files/upload",
|
UPLOAD: "files/upload",
|
||||||
@@ -55,6 +56,7 @@ export const API_ENDPOINTS = {
|
|||||||
DELETE: (id: string) => `customers/${id}`,
|
DELETE: (id: string) => `customers/${id}`,
|
||||||
DETAILS: (id: string) => `customers/${id}`,
|
DETAILS: (id: string) => `customers/${id}`,
|
||||||
ASSIGN_COMPANY_TO_CUSTOMER: (id: string) => `customers/${id}/companies`,
|
ASSIGN_COMPANY_TO_CUSTOMER: (id: string) => `customers/${id}/companies`,
|
||||||
|
RESET_PASSWORD: (id: string) => `customers/${id}/reset-password`,
|
||||||
},
|
},
|
||||||
FEEDBACK: {
|
FEEDBACK: {
|
||||||
READ_ALL: "feedback",
|
READ_ALL: "feedback",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import api from "../axios";
|
import api from "../axios";
|
||||||
import { API_ENDPOINTS } from "../endpoints";
|
import { API_ENDPOINTS } from "../endpoints";
|
||||||
import { ApiResponse } from "../types";
|
import { ApiResponse, TResetPasswordResponse } from "../types";
|
||||||
import {
|
import {
|
||||||
CreateCustomerCredentials,
|
CreateCustomerCredentials,
|
||||||
CustomerListResponseSchema,
|
CustomerListResponseSchema,
|
||||||
@@ -57,6 +57,19 @@ export const customersService = {
|
|||||||
const response = await api.get(API_ENDPOINTS.CUSTOMERS.DETAILS(id));
|
const response = await api.get(API_ENDPOINTS.CUSTOMERS.DETAILS(id));
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
resetCustomerPassword: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<ApiResponse<TResetPasswordResponse>> => {
|
||||||
|
try {
|
||||||
|
return (await api.post(API_ENDPOINTS.CUSTOMERS.RESET_PASSWORD(id))).data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"ERROR caught in Customers Service Reset Password:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
assignCompanyToCustomer: async ({
|
assignCompanyToCustomer: async ({
|
||||||
credentials,
|
credentials,
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
LoginResponseSchema,
|
LoginResponseSchema,
|
||||||
LogoutResponseSchema,
|
LogoutResponseSchema,
|
||||||
TChangeAdminStatusCredentials,
|
TChangeAdminStatusCredentials,
|
||||||
|
TResetPasswordResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
@@ -92,6 +93,16 @@ export const userService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
resetAdminPassword: async (
|
||||||
|
id: string,
|
||||||
|
): Promise<ApiResponse<TResetPasswordResponse>> => {
|
||||||
|
try {
|
||||||
|
return (await api.post(API_ENDPOINTS.USER.RESET_ADMIN_PASSWORD(id))).data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ERROR caught in User Service Reset Admin Password", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
changeAdminStatus: async ({
|
changeAdminStatus: async ({
|
||||||
id,
|
id,
|
||||||
credentials,
|
credentials,
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ export const LogoutResponse = z.object({
|
|||||||
export const changeAdminStatusCredentialsSchema = z.object({
|
export const changeAdminStatusCredentialsSchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
});
|
});
|
||||||
|
export const ResetPasswordResponseSchema = z.object({
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
export type IUser = z.infer<typeof UserSchema>;
|
export type IUser = z.infer<typeof UserSchema>;
|
||||||
export type ILogoutResponse = z.infer<typeof LogoutResponse>;
|
export type ILogoutResponse = z.infer<typeof LogoutResponse>;
|
||||||
export type ILoginCredentials = z.infer<typeof LoginCredentials>;
|
export type ILoginCredentials = z.infer<typeof LoginCredentials>;
|
||||||
@@ -77,6 +80,9 @@ export type IAdminListResponse = z.infer<typeof AdminListResponse>;
|
|||||||
export type TChangeAdminStatusCredentials = z.infer<
|
export type TChangeAdminStatusCredentials = z.infer<
|
||||||
typeof changeAdminStatusCredentialsSchema
|
typeof changeAdminStatusCredentialsSchema
|
||||||
>;
|
>;
|
||||||
|
export type TResetPasswordResponse = z.infer<
|
||||||
|
typeof ResetPasswordResponseSchema
|
||||||
|
>;
|
||||||
export const LogoutResponseSchema = createApiResponseSchema(LogoutResponse);
|
export const LogoutResponseSchema = createApiResponseSchema(LogoutResponse);
|
||||||
export const LoginResponseSchema = createApiResponseSchema(LoginResponse);
|
export const LoginResponseSchema = createApiResponseSchema(LoginResponse);
|
||||||
export const AdminListResponseSchema =
|
export const AdminListResponseSchema =
|
||||||
|
|||||||
Reference in New Issue
Block a user