Notification toasts, nicer loading, add new user support

This commit is contained in:
Jamie Curnow
2025-09-03 18:01:00 +10:00
parent ebd9148813
commit 5a01da2916
33 changed files with 414 additions and 215 deletions

View File

@@ -30,6 +30,7 @@
"react-dom": "^19.1.1",
"react-intl": "^7.1.11",
"react-router-dom": "^7.8.2",
"react-toastify": "^11.0.5",
"rooks": "^9.2.0"
},
"devDependencies": {

View File

@@ -5,3 +5,10 @@
.domain-name {
font-family: monospace;
}
.mr-1 {
margin-right: 0.25rem;
}
.ml-1 {
margin-left: 0.25rem;
}

View File

@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { RawIntlProvider } from "react-intl";
import { ToastContainer } from "react-toastify";
import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
import { intl } from "src/locale";
import Router from "src/Router.tsx";
@@ -16,6 +17,15 @@ function App() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router />
<ToastContainer
position="top-right"
autoClose={5000}
hideProgressBar={true}
newestOnTop={true}
closeOnClick={true}
rtl={false}
closeButton={false}
/>
</AuthProvider>
<ReactQueryDevtools buttonPosition="bottom-right" position="right" />
</QueryClientProvider>

View File

@@ -4,6 +4,7 @@ export * from "./createDeadHost";
export * from "./createProxyHost";
export * from "./createRedirectionHost";
export * from "./createStream";
export * from "./createUser";
export * from "./deleteAccessList";
export * from "./deleteCertificate";
export * from "./deleteDeadHost";

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import Alert from "react-bootstrap/Alert";
import { Loading, LoadingPage } from "src/components";
import { useUser } from "src/hooks";
import { intl } from "src/locale";
@@ -8,11 +9,30 @@ interface Props {
type: "manage" | "view";
hideError?: boolean;
children?: ReactNode;
pageLoading?: boolean;
loadingNoLogo?: boolean;
}
function HasPermission({ permission, type, children, hideError = false }: Props) {
const { data } = useUser("me");
function HasPermission({
permission,
type,
children,
hideError = false,
pageLoading = false,
loadingNoLogo = false,
}: Props) {
const { data, isLoading } = useUser("me");
const perms = data?.permissions;
if (isLoading) {
if (hideError) {
return null;
}
if (pageLoading) {
return <LoadingPage noLogo={loadingNoLogo} />;
}
return <Loading noLogo={loadingNoLogo} />;
}
let allowed = permission === "";
const acceptable = ["manage", type];

View File

@@ -0,0 +1,22 @@
import { intl } from "src/locale";
import styles from "./Loading.module.css";
interface Props {
label?: string;
noLogo?: boolean;
}
export function Loading({ label, noLogo }: Props) {
return (
<div className="empty text-center">
{noLogo ? null : (
<div className="mb-3">
<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
</div>
)}
<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
<div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate" />
</div>
</div>
);
}

View File

@@ -1,6 +1,4 @@
import { Page } from "src/components";
import { intl } from "src/locale";
import styles from "./LoadingPage.module.css";
import { Loading, Page } from "src/components";
interface Props {
label?: string;
@@ -10,17 +8,7 @@ export function LoadingPage({ label, noLogo }: Props) {
return (
<Page className="page-center">
<div className="container-tight py-4">
<div className="empty text-center">
{noLogo ? null : (
<div className="mb-3">
<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
</div>
)}
<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
<div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate" />
</div>
</div>
<Loading label={label} noLogo={noLogo} />
</div>
</Page>
);

View File

@@ -2,6 +2,7 @@ export * from "./Button";
export * from "./ErrorNotFound";
export * from "./Flag";
export * from "./HasPermission";
export * from "./Loading";
export * from "./LoadingPage";
export * from "./LocalePicker";
export * from "./NavLink";

View File

@@ -1,7 +1,20 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getUser, type User, updateUser } from "src/api/backend";
import { createUser, getUser, type User, updateUser } from "src/api/backend";
const fetchUser = (id: number | string) => {
if (id === "new") {
return Promise.resolve({
id: 0,
createdOn: "",
modifiedOn: "",
isDisabled: false,
email: "",
name: "",
nickname: "",
roles: [],
avatar: "",
} as User);
}
return getUser(id, { expand: "permissions" });
};
@@ -17,8 +30,11 @@ const useUser = (id: string | number, options = {}) => {
const useSetUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: User) => updateUser(values),
mutationFn: (values: User) => (values.id ? updateUser(values) : createUser(values)),
onMutate: (values: User) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["user", values.id]);
queryClient.setQueryData(["user", values.id], (old: User) => ({
...old,

View File

@@ -51,6 +51,9 @@
"notfound.action": "Take me home",
"notfound.text": "We are sorry but the page you are looking for was not found",
"notfound.title": "Oops… You just found an error page",
"notification.error": "Error",
"notification.success": "Success",
"notification.user-saved": "User has been saved",
"offline": "Offline",
"online": "Online",
"password": "Password",
@@ -82,6 +85,7 @@
"user.edit-profile": "Edit Profile",
"user.full-name": "Full Name",
"user.logout": "Logout",
"user.new": "New User",
"user.new-password": "New Password",
"user.nickname": "Nickname",
"user.switch-dark": "Switch to Dark mode",

View File

@@ -155,6 +155,15 @@
"notfound.title": {
"defaultMessage": "Oops… You just found an error page"
},
"notification.error": {
"defaultMessage": "Error"
},
"notification.user-saved": {
"defaultMessage": "User has been saved"
},
"notification.success": {
"defaultMessage": "Success"
},
"offline": {
"defaultMessage": "Offline"
},
@@ -248,6 +257,9 @@
"user.logout": {
"defaultMessage": "Logout"
},
"user.new": {
"defaultMessage": "New User"
},
"user.new-password": {
"defaultMessage": "New Password"
},

View File

@@ -2,31 +2,35 @@ import { Field, Form, Formik } from "formik";
import { useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button } from "src/components";
import { Button, Loading } from "src/components";
import { useSetUser, useUser } from "src/hooks";
import { intl } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications";
interface Props {
userId: number | "me";
userId: number | "me" | "new";
onClose: () => void;
}
export function UserModal({ userId, onClose }: Props) {
const { data } = useUser(userId);
const { data: currentUser } = useUser("me");
const { data, isLoading, error } = useUser(userId);
const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
const { mutate: setUser } = useSetUser();
const [error, setError] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
if (data && currentUser) {
console.log("DATA:", data);
console.log("CURRENT:", currentUser);
}
const onSubmit = async (values: any, { setSubmitting }: any) => {
setError(null);
setErrorMsg(null);
const { ...payload } = {
id: userId,
id: userId === "new" ? undefined : userId,
roles: [],
...values,
};
console.log("values", values);
if (data?.id === currentUser?.id) {
// Prevent user from locking themselves out
delete payload.isDisabled;
@@ -39,175 +43,188 @@ export function UserModal({ userId, onClose }: Props) {
delete payload.isAdmin;
setUser(payload, {
onError: (err: any) => setError(err.message),
onSuccess: () => onClose(),
onError: (err: any) => setErrorMsg(err.message),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
onClose();
},
onSettled: () => setSubmitting(false),
});
};
return (
<Modal show onHide={onClose} animation={false}>
<Formik
initialValues={
{
name: data?.name,
nickname: data?.nickname,
email: data?.email,
isAdmin: data?.roles.includes("admin"),
isDisabled: data?.isDisabled,
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>{intl.formatMessage({ id: "user.edit" })}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
{error}
</Alert>
<div className="row">
<div className="col-lg-6">
<div className="mb-3">
<Field name="name" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="name"
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.full-name" })}
{...field}
/>
<label htmlFor="name">
{intl.formatMessage({ id: "user.full-name" })}
</label>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="col-lg-6">
<div className="mb-3">
<Field name="nickname" validate={validateString(1, 30)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="nickname"
className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.nickname" })}
{...field}
/>
<label htmlFor="nickname">
{intl.formatMessage({ id: "user.nickname" })}
</label>
{form.errors.nickname ? (
<div className="invalid-feedback">
{form.errors.nickname && form.touched.nickname
? form.errors.nickname
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
</div>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="email"
type="email"
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
{...field}
/>
<label htmlFor="email">{intl.formatMessage({ id: "email-address" })}</label>
{form.errors.email ? (
<div className="invalid-feedback">
{form.errors.email && form.touched.email ? form.errors.email : null}
</div>
) : null}
{!isLoading && error && <Alert variant="danger">{error?.message || "Unknown error"}</Alert>}
{(isLoading || currentIsLoading) && <Loading noLogo />}
{!isLoading && !currentIsLoading && data && currentUser && (
<Formik
initialValues={
{
name: data?.name,
nickname: data?.nickname,
email: data?.email,
isAdmin: data?.roles?.includes("admin"),
isDisabled: data?.isDisabled,
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
{intl.formatMessage({ id: data?.id ? "user.edit" : "user.new" })}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<div className="row">
<div className="col-lg-6">
<div className="mb-3">
<Field name="name" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="name"
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.full-name" })}
{...field}
/>
<label htmlFor="name">
{intl.formatMessage({ id: "user.full-name" })}
</label>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
)}
</Field>
</div>
{currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3">
<h3 className="py-2">Properties</h3>
</div>
<div className="col-lg-6">
<div className="mb-3">
<Field name="nickname" validate={validateString(1, 30)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="nickname"
className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.nickname" })}
{...field}
/>
<label htmlFor="nickname">
{intl.formatMessage({ id: "user.nickname" })}
</label>
{form.errors.nickname ? (
<div className="invalid-feedback">
{form.errors.nickname && form.touched.nickname
? form.errors.nickname
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
</div>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="email"
type="email"
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
{...field}
/>
<label htmlFor="email">
{intl.formatMessage({ id: "email-address" })}
</label>
{form.errors.email ? (
<div className="invalid-feedback">
{form.errors.email && form.touched.email
? form.errors.email
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
{currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3">
<h3 className="py-2">Properties</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="isAdmin">
<span className="col">Administrator</span>
<span className="col-auto">
<Field name="isAdmin" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isAdmin"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="isDisabled">
<span className="col">Disabled</span>
<span className="col-auto">
<Field name="isDisabled" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isDisabled"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
<div className="divide-y">
<div>
<label className="row" htmlFor="isAdmin">
<span className="col">Administrator</span>
<span className="col-auto">
<Field name="isAdmin" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isAdmin"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="isDisabled">
<span className="col">Disabled</span>
<span className="col-auto">
<Field name="isDisabled" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isDisabled"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
) : null}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
{intl.formatMessage({ id: "cancel" })}
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{intl.formatMessage({ id: "save" })}
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
) : null}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
{intl.formatMessage({ id: "cancel" })}
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{intl.formatMessage({ id: "save" })}
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
)}
</Modal>
);
}

View File

@@ -0,0 +1,14 @@
.toaster {
padding: 0;
background: transparent !important;
box-shadow: none !important;
border: none !important;
&.toast {
border-radius: 0;
box-shadow: none;
font-size: 14px;
padding: 16px 24px;
background: transparent;
}
}

View File

@@ -0,0 +1,36 @@
import { IconCheck, IconExclamationCircle } from "@tabler/icons-react";
import cn from "classnames";
import type { ReactNode } from "react";
function Msg({ data }: any) {
const cns = cn("toast", "show", data.type || null);
let icon: ReactNode = null;
switch (data.type) {
case "success":
icon = <IconCheck className="text-green mr-1" size={16} />;
break;
case "error":
icon = <IconExclamationCircle className="text-red mr-1" size={16} />;
break;
}
return (
<div
className={cns}
role="alert"
aria-live="assertive"
aria-atomic="true"
data-bs-autohide="false"
data-bs-toggle="toast"
>
{data.title && (
<div className="toast-header">
{icon} {data.title}
</div>
)}
<div className="toast-body">{data.message}</div>
</div>
);
}
export { Msg };

View File

@@ -0,0 +1,27 @@
import { toast } from "react-toastify";
import { intl } from "src/locale";
import { Msg } from "./Msg";
import styles from "./Msg.module.css";
const showSuccess = (message: string) => {
toast(Msg, {
className: styles.toaster,
data: {
type: "success",
title: intl.formatMessage({ id: "notification.success" }),
message,
},
});
};
const showError = (message: string) => {
toast(<Msg />, {
data: {
type: "error",
title: intl.formatMessage({ id: "notification.error" }),
message,
},
});
};
export { showSuccess, showError };

View File

@@ -0,0 +1 @@
export * from "./helpers";

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const Access = () => {
return (
<HasPermission permission="accessLists" type="view">
<HasPermission permission="accessLists" type="view" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import AuditTable from "./AuditTable";
const AuditLog = () => {
return (
<HasPermission permission="admin" type="manage">
<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
<AuditTable />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import CertificateTable from "./CertificateTable";
const Certificates = () => {
return (
<HasPermission permission="certificates" type="view">
<HasPermission permission="certificates" type="view" pageLoading loadingNoLogo>
<CertificateTable />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const DeadHosts = () => {
return (
<HasPermission permission="deadHosts" type="view">
<HasPermission permission="deadHosts" type="view" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const ProxyHosts = () => {
return (
<HasPermission permission="proxyHosts" type="view">
<HasPermission permission="proxyHosts" type="view" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const RedirectionHosts = () => {
return (
<HasPermission permission="redirectionHosts" type="view">
<HasPermission permission="redirectionHosts" type="view" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const Streams = () => {
return (
<HasPermission permission="streams" type="view">
<HasPermission permission="streams" type="view" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -3,7 +3,7 @@ import SettingTable from "./SettingTable";
const Settings = () => {
return (
<HasPermission permission="admin" type="manage">
<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
<SettingTable />
</HasPermission>
);

View File

@@ -4,15 +4,18 @@ import { intl } from "src/locale";
interface Props {
tableInstance: ReactTable<any>;
onNewUser?: () => void;
}
export default function Empty({ tableInstance }: Props) {
export default function Empty({ tableInstance, onNewUser }: Props) {
return (
<tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4">
<h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<Button className="btn-lime my-3">{intl.formatMessage({ id: "proxy-hosts.add" })}</Button>
<Button className="btn-lime my-3" onClick={onNewUser}>
{intl.formatMessage({ id: "proxy-hosts.add" })}
</Button>
</div>
</td>
</tr>

View File

@@ -12,8 +12,9 @@ interface Props {
isFetching?: boolean;
currentUserId?: number;
onEditUser?: (id: number) => void;
onNewUser?: () => void;
}
export default function Table({ data, isFetching, currentUserId, onEditUser }: Props) {
export default function Table({ data, isFetching, currentUserId, onEditUser, onNewUser }: Props) {
const columnHelper = createColumnHelper<User>();
const columns = useMemo(
() => [
@@ -124,5 +125,10 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
enableSortingRemoval: false,
});
return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
return (
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} />}
/>
);
}

View File

@@ -8,7 +8,7 @@ import { UserModal } from "src/modals";
import Table from "./Table";
export default function TableWrapper() {
const [editUserId, setEditUserId] = useState(0);
const [editUserId, setEditUserId] = useState(0 as number | "new");
const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
const { data: currentUser } = useUser("me");
@@ -42,7 +42,7 @@ export default function TableWrapper() {
autoComplete="off"
/>
</div>
<Button size="sm" className="btn-orange">
<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
{intl.formatMessage({ id: "users.add" })}
</Button>
</div>
@@ -54,6 +54,7 @@ export default function TableWrapper() {
isFetching={isFetching}
currentUserId={currentUser?.id}
onEditUser={(id: number) => setEditUserId(id)}
onNewUser={() => setEditUserId("new")}
/>
{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
</div>

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const Users = () => {
return (
<HasPermission permission="admin" type="manage">
<HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
<TableWrapper />
</HasPermission>
);

View File

@@ -1064,6 +1064,11 @@ classnames@^2.3.2, classnames@^2.5.1:
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
convert-source-map@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
@@ -1607,6 +1612,13 @@ react-router@7.8.2:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
react-toastify@^11.0.5:
version "11.0.5"
resolved "https://registry.yarnpkg.com/react-toastify/-/react-toastify-11.0.5.tgz#ce4c42d10eeb433988ab2264d3e445c4e9d13313"
integrity sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==
dependencies:
clsx "^2.1.1"
react-transition-group@^4.4.5:
version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"