Compare commits

..

2 Commits

Author SHA1 Message Date
Jamie Curnow
432afe73ad User table polishing, user delete modal 2025-09-04 14:59:01 +10:00
Jamie Curnow
5a01da2916 Notification toasts, nicer loading, add new user support 2025-09-04 12:11:39 +10:00
41 changed files with 601 additions and 248 deletions

View File

@@ -134,24 +134,24 @@ export default {
* @param {Object} user * @param {Object} user
* @returns {Promise} * @returns {Promise}
*/ */
getTokenFromUser: (user) => { getTokenFromUser: async (user) => {
const expire = "1d"; const expire = "1d";
const Token = new TokenModel(); const Token = new TokenModel();
const expiry = parseDatePeriod(expire); const expiry = parseDatePeriod(expire);
return Token.create({ const signed = await Token.create({
iss: "api", iss: "api",
attrs: { attrs: {
id: user.id, id: user.id,
}, },
scope: ["user"], scope: ["user"],
expiresIn: expire, expiresIn: expire,
}).then((signed) => {
return {
token: signed.token,
expires: expiry.toISOString(),
user: user,
};
}); });
return {
token: signed.token,
expires: expiry.toISOString(),
user: user,
};
}, },
}; };

View File

@@ -337,11 +337,11 @@ const internalUser = {
* @param {Integer} [id_requested] * @param {Integer} [id_requested]
* @returns {[String]} * @returns {[String]}
*/ */
getUserOmisionsByAccess: (access, id_requested) => { getUserOmisionsByAccess: (access, idRequested) => {
let response = []; // Admin response let response = []; // Admin response
if (!access.token.hasScope("admin") && access.token.getUserId(0) !== id_requested) { if (!access.token.hasScope("admin") && access.token.getUserId(0) !== idRequested) {
response = ["roles", "is_deleted"]; // Restricted response response = ["is_deleted"]; // Restricted response
} }
return response; return response;

View File

@@ -13,7 +13,7 @@ import { global as logger } from "../logger.js";
const ALGO = "RS256"; const ALGO = "RS256";
export default () => { export default () => {
let token_data = {}; let tokenData = {};
const self = { const self = {
/** /**
@@ -37,7 +37,7 @@ export default () => {
if (err) { if (err) {
reject(err); reject(err);
} else { } else {
token_data = payload; tokenData = payload;
resolve({ resolve({
token: token, token: token,
payload: payload, payload: payload,
@@ -72,18 +72,18 @@ export default () => {
reject(err); reject(err);
} }
} else { } else {
token_data = result; tokenData = result;
// Hack: some tokens out in the wild have a scope of 'all' instead of 'user'. // Hack: some tokens out in the wild have a scope of 'all' instead of 'user'.
// For 30 days at least, we need to replace 'all' with user. // For 30 days at least, we need to replace 'all' with user.
if ( if (
typeof token_data.scope !== "undefined" && typeof tokenData.scope !== "undefined" &&
_.indexOf(token_data.scope, "all") !== -1 _.indexOf(tokenData.scope, "all") !== -1
) { ) {
token_data.scope = ["user"]; tokenData.scope = ["user"];
} }
resolve(token_data); resolve(tokenData);
} }
}, },
); );
@@ -100,15 +100,15 @@ export default () => {
* @param {String} scope * @param {String} scope
* @returns {Boolean} * @returns {Boolean}
*/ */
hasScope: (scope) => typeof token_data.scope !== "undefined" && _.indexOf(token_data.scope, scope) !== -1, hasScope: (scope) => typeof tokenData.scope !== "undefined" && _.indexOf(tokenData.scope, scope) !== -1,
/** /**
* @param {String} key * @param {String} key
* @return {*} * @return {*}
*/ */
get: (key) => { get: (key) => {
if (typeof token_data[key] !== "undefined") { if (typeof tokenData[key] !== "undefined") {
return token_data[key]; return tokenData[key];
} }
return null; return null;
@@ -119,20 +119,20 @@ export default () => {
* @param {*} value * @param {*} value
*/ */
set: (key, value) => { set: (key, value) => {
token_data[key] = value; tokenData[key] = value;
}, },
/** /**
* @param [default_value] * @param [defaultValue]
* @returns {Integer} * @returns {Integer}
*/ */
getUserId: (default_value) => { getUserId: (defaultValue) => {
const attrs = self.get("attrs"); const attrs = self.get("attrs");
if (attrs && typeof attrs.id !== "undefined" && attrs.id) { if (attrs && typeof attrs.id !== "undefined" && attrs.id) {
return attrs.id; return attrs.id;
} }
return default_value || 0; return defaultValue || 0;
}, },
}; };

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { Loading, LoadingPage } from "src/components";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { intl } from "src/locale"; import { intl } from "src/locale";
@@ -8,11 +9,30 @@ interface Props {
type: "manage" | "view"; type: "manage" | "view";
hideError?: boolean; hideError?: boolean;
children?: ReactNode; children?: ReactNode;
pageLoading?: boolean;
loadingNoLogo?: boolean;
} }
function HasPermission({ permission, type, children, hideError = false }: Props) { function HasPermission({
const { data } = useUser("me"); permission,
type,
children,
hideError = false,
pageLoading = false,
loadingNoLogo = false,
}: Props) {
const { data, isLoading } = useUser("me");
const perms = data?.permissions; const perms = data?.permissions;
if (isLoading) {
if (hideError) {
return null;
}
if (pageLoading) {
return <LoadingPage noLogo={loadingNoLogo} />;
}
return <Loading noLogo={loadingNoLogo} />;
}
let allowed = permission === ""; let allowed = permission === "";
const acceptable = ["manage", type]; 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 { Loading, Page } from "src/components";
import { intl } from "src/locale";
import styles from "./LoadingPage.module.css";
interface Props { interface Props {
label?: string; label?: string;
@@ -10,17 +8,7 @@ export function LoadingPage({ label, noLogo }: Props) {
return ( return (
<Page className="page-center"> <Page className="page-center">
<div className="container-tight py-4"> <div className="container-tight py-4">
<div className="empty text-center"> <Loading label={label} noLogo={noLogo} />
{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>
</div> </div>
</Page> </Page>
); );

View File

@@ -66,7 +66,9 @@ export function SiteHeader() {
<div className="d-none d-xl-block ps-2"> <div className="d-none d-xl-block ps-2">
<div>{currentUser?.nickname}</div> <div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary"> <div className="mt-1 small text-secondary">
{intl.formatMessage({ id: isAdmin ? "administrator" : "standard-user" })} {intl.formatMessage({
id: isAdmin ? "role.admin" : "role.standard-user",
})}
</div> </div>
</div> </div>
</a> </a>

View File

@@ -10,9 +10,9 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
{domains.map((domain: string) => ( {domains.map((domain: string) => (
<span key={domain} className="badge badge-lg domain-name"> <a key={domain} href={`http://${domain}`} className="badge bg-yellow-lt domain-name">
{domain} {domain}
</span> </a>
))} ))}
</div> </div>
{createdOn ? ( {createdOn ? (

View File

@@ -0,0 +1,10 @@
interface Props {
email: string;
}
export function EmailFormatter({ email }: Props) {
return (
<a href={`mailto:${email}`} className="badge bg-yellow-lt">
{email}
</a>
);
}

View File

@@ -0,0 +1,20 @@
import { intl } from "src/locale";
interface Props {
roles: string[];
}
export function RolesFormatter({ roles }: Props) {
const r = roles || [];
if (r.length === 0) {
r[0] = "standard-user";
}
return (
<>
{r.map((role: string) => (
<span key={role} className="badge bg-yellow-lt me-1">
{intl.formatMessage({ id: `role.${role}` })}
</span>
))}
</>
);
}

View File

@@ -4,16 +4,19 @@ import { intl } from "src/locale";
interface Props { interface Props {
value: string; value: string;
createdOn?: string; createdOn?: string;
disabled?: boolean;
} }
export function ValueWithDateFormatter({ value, createdOn }: Props) { export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
return ( return (
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
<div className="font-weight-medium">{value}</div> <div className={`font-weight-medium ${disabled ? "text-red" : ""}`}>{value}</div>
</div> </div>
{createdOn ? ( {createdOn ? (
<div className="text-secondary mt-1"> <div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
{intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })} {disabled
? intl.formatMessage({ id: "disabled" })
: intl.formatMessage({ id: "created-on" }, { date: intlFormat(parseISO(createdOn)) })}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,5 +1,7 @@
export * from "./CertificateFormatter"; export * from "./CertificateFormatter";
export * from "./DomainsFormatter"; export * from "./DomainsFormatter";
export * from "./EmailFormatter";
export * from "./GravatarFormatter"; export * from "./GravatarFormatter";
export * from "./RolesFormatter";
export * from "./StatusFormatter"; export * from "./StatusFormatter";
export * from "./ValueWithDateFormatter"; export * from "./ValueWithDateFormatter";

View File

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

View File

@@ -1,7 +1,20 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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) => { 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" }); return getUser(id, { expand: "permissions" });
}; };
@@ -17,8 +30,11 @@ const useUser = (id: string | number, options = {}) => {
const useSetUser = () => { const useSetUser = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (values: User) => updateUser(values), mutationFn: (values: User) => (values.id ? updateUser(values) : createUser(values)),
onMutate: (values: User) => { onMutate: (values: User) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["user", values.id]); const previousObject = queryClient.getQueryData(["user", values.id]);
queryClient.setQueryData(["user", values.id], (old: User) => ({ queryClient.setQueryData(["user", values.id], (old: User) => ({
...old, ...old,

View File

@@ -12,7 +12,6 @@
"action.edit": "Edit", "action.edit": "Edit",
"action.enable": "Enable", "action.enable": "Enable",
"action.permissions": "Permissions", "action.permissions": "Permissions",
"administrator": "Administrator",
"auditlog.title": "Audit Log", "auditlog.title": "Audit Log",
"cancel": "Cancel", "cancel": "Cancel",
"certificates.title": "SSL Certificates", "certificates.title": "SSL Certificates",
@@ -37,6 +36,7 @@
"dead-hosts.count": "{count} 404 Hosts", "dead-hosts.count": "{count} 404 Hosts",
"dead-hosts.empty": "There are no 404 Hosts", "dead-hosts.empty": "There are no 404 Hosts",
"dead-hosts.title": "404 Hosts", "dead-hosts.title": "404 Hosts",
"disabled": "Disabled",
"email-address": "Email address", "email-address": "Email address",
"empty-subtitle": "Why don't you create one?", "empty-subtitle": "Why don't you create one?",
"error.invalid-auth": "Invalid email or password", "error.invalid-auth": "Invalid email or password",
@@ -51,6 +51,10 @@
"notfound.action": "Take me home", "notfound.action": "Take me home",
"notfound.text": "We are sorry but the page you are looking for was not found", "notfound.text": "We are sorry but the page you are looking for was not found",
"notfound.title": "Oops… You just found an error page", "notfound.title": "Oops… You just found an error page",
"notification.error": "Error",
"notification.success": "Success",
"notification.user-deleted": "User has been deleted",
"notification.user-saved": "User has been saved",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",
"password": "Password", "password": "Password",
@@ -64,10 +68,11 @@
"redirection-hosts.count": "{count} Redirection Hosts", "redirection-hosts.count": "{count} Redirection Hosts",
"redirection-hosts.empty": "There are no Redirection Hosts", "redirection-hosts.empty": "There are no Redirection Hosts",
"redirection-hosts.title": "Redirection Hosts", "redirection-hosts.title": "Redirection Hosts",
"role.admin": "Administrator",
"role.standard-user": "Standard User",
"save": "Save", "save": "Save",
"settings.title": "Settings", "settings.title": "Settings",
"sign-in": "Sign in", "sign-in": "Sign in",
"standard-user": "Apache Helicopter",
"streams.actions-title": "Stream #{id}", "streams.actions-title": "Stream #{id}",
"streams.add": "Add Stream", "streams.add": "Add Stream",
"streams.count": "{count} Streams", "streams.count": "{count} Streams",
@@ -78,10 +83,14 @@
"user.change-password": "Change Password", "user.change-password": "Change Password",
"user.confirm-password": "Confirm Password", "user.confirm-password": "Confirm Password",
"user.current-password": "Current Password", "user.current-password": "Current Password",
"user.delete.content": "Are you sure you want to delete this user?",
"user.delete.title": "Delete User",
"user.edit": "Edit User", "user.edit": "Edit User",
"user.edit-profile": "Edit Profile", "user.edit-profile": "Edit Profile",
"user.flags.title": "Properties",
"user.full-name": "Full Name", "user.full-name": "Full Name",
"user.logout": "Logout", "user.logout": "Logout",
"user.new": "New User",
"user.new-password": "New Password", "user.new-password": "New Password",
"user.nickname": "Nickname", "user.nickname": "Nickname",
"user.switch-dark": "Switch to Dark mode", "user.switch-dark": "Switch to Dark mode",

View File

@@ -38,9 +38,6 @@
"action.permissions": { "action.permissions": {
"defaultMessage": "Permissions" "defaultMessage": "Permissions"
}, },
"administrator": {
"defaultMessage": "Administrator"
},
"auditlog.title": { "auditlog.title": {
"defaultMessage": "Audit Log" "defaultMessage": "Audit Log"
}, },
@@ -113,6 +110,9 @@
"dead-hosts.title": { "dead-hosts.title": {
"defaultMessage": "404 Hosts" "defaultMessage": "404 Hosts"
}, },
"disabled": {
"defaultMessage": "Disabled"
},
"email-address": { "email-address": {
"defaultMessage": "Email address" "defaultMessage": "Email address"
}, },
@@ -155,6 +155,18 @@
"notfound.title": { "notfound.title": {
"defaultMessage": "Oops… You just found an error page" "defaultMessage": "Oops… You just found an error page"
}, },
"notification.error": {
"defaultMessage": "Error"
},
"notification.user-deleted": {
"defaultMessage": "User has been deleted"
},
"notification.user-saved": {
"defaultMessage": "User has been saved"
},
"notification.success": {
"defaultMessage": "Success"
},
"offline": { "offline": {
"defaultMessage": "Offline" "defaultMessage": "Offline"
}, },
@@ -194,6 +206,12 @@
"redirection-hosts.title": { "redirection-hosts.title": {
"defaultMessage": "Redirection Hosts" "defaultMessage": "Redirection Hosts"
}, },
"role.admin": {
"defaultMessage": "Administrator"
},
"role.standard-user": {
"defaultMessage": "Standard User"
},
"save": { "save": {
"defaultMessage": "Save" "defaultMessage": "Save"
}, },
@@ -203,9 +221,6 @@
"sign-in": { "sign-in": {
"defaultMessage": "Sign in" "defaultMessage": "Sign in"
}, },
"standard-user": {
"defaultMessage": "Apache Helicopter"
},
"streams.actions-title": { "streams.actions-title": {
"defaultMessage": "Stream #{id}" "defaultMessage": "Stream #{id}"
}, },
@@ -236,18 +251,30 @@
"user.current-password": { "user.current-password": {
"defaultMessage": "Current Password" "defaultMessage": "Current Password"
}, },
"user.delete.title": {
"defaultMessage": "Delete User"
},
"user.delete.content": {
"defaultMessage": "Are you sure you want to delete this user?"
},
"user.edit": { "user.edit": {
"defaultMessage": "Edit User" "defaultMessage": "Edit User"
}, },
"user.edit-profile": { "user.edit-profile": {
"defaultMessage": "Edit Profile" "defaultMessage": "Edit Profile"
}, },
"user.flags.title": {
"defaultMessage": "Properties"
},
"user.full-name": { "user.full-name": {
"defaultMessage": "Full Name" "defaultMessage": "Full Name"
}, },
"user.logout": { "user.logout": {
"defaultMessage": "Logout" "defaultMessage": "Logout"
}, },
"user.new": {
"defaultMessage": "New User"
},
"user.new-password": { "user.new-password": {
"defaultMessage": "New Password" "defaultMessage": "New Password"
}, },

View File

@@ -0,0 +1,65 @@
import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button } from "src/components";
import { intl } from "src/locale";
interface Props {
title: string;
children: ReactNode;
onConfirm: () => Promise<void> | void;
onClose: () => void;
invalidations?: any[];
}
export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
setSubmitting(true);
setError(null);
try {
await onConfirm();
onClose();
// invalidate caches as requested
invalidations?.forEach((inv) => {
queryClient.invalidateQueries({ queryKey: inv });
});
} catch (err: any) {
setError(intl.formatMessage({ id: err.message }));
}
setSubmitting(false);
};
return (
<Modal show onHide={onClose} animation={false}>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
{error}
</Alert>
{children}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={submitting}>
{intl.formatMessage({ id: "cancel" })}
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto btn-red"
data-bs-dismiss="modal"
isLoading={submitting}
disabled={submitting}
onClick={onSubmit}
>
{intl.formatMessage({ id: "action.delete" })}
</Button>
</Modal.Footer>
</Modal>
);
}

View File

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

View File

@@ -1,2 +1,3 @@
export * from "./ChangePasswordModal"; export * from "./ChangePasswordModal";
export * from "./DeleteConfirmModal";
export * from "./UserModal"; export * from "./UserModal";

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 = () => { const Access = () => {
return ( return (
<HasPermission permission="accessLists" type="view"> <HasPermission permission="accessLists" type="view" pageLoading loadingNoLogo>
<TableWrapper /> <TableWrapper />
</HasPermission> </HasPermission>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ import { IconDotsVertical, IconEdit, IconLock, IconShield, IconTrash } from "@ta
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { User } from "src/api/backend"; import type { User } from "src/api/backend";
import { GravatarFormatter, ValueWithDateFormatter } from "src/components"; import { EmailFormatter, GravatarFormatter, RolesFormatter, ValueWithDateFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
@@ -12,8 +12,10 @@ interface Props {
isFetching?: boolean; isFetching?: boolean;
currentUserId?: number; currentUserId?: number;
onEditUser?: (id: number) => void; onEditUser?: (id: number) => void;
onDeleteUser?: (id: number) => void;
onNewUser?: () => void;
} }
export default function Table({ data, isFetching, currentUserId, onEditUser }: Props) { export default function Table({ data, isFetching, currentUserId, onEditUser, onDeleteUser, onNewUser }: Props) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -33,14 +35,20 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
cell: (info: any) => { cell: (info: any) => {
const value = info.getValue(); const value = info.getValue();
// Hack to reuse domains formatter // Hack to reuse domains formatter
return <ValueWithDateFormatter value={value.name} createdOn={value.createdOn} />; return (
<ValueWithDateFormatter
value={value.name}
createdOn={value.createdOn}
disabled={value.isDisabled}
/>
);
}, },
}), }),
columnHelper.accessor((row: any) => row.email, { columnHelper.accessor((row: any) => row.email, {
id: "email", id: "email",
header: intl.formatMessage({ id: "column.email" }), header: intl.formatMessage({ id: "column.email" }),
cell: (info: any) => { cell: (info: any) => {
return info.getValue(); return <EmailFormatter email={info.getValue()} />;
}, },
}), }),
// TODO: formatter for roles // TODO: formatter for roles
@@ -48,7 +56,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
id: "roles", id: "roles",
header: intl.formatMessage({ id: "column.roles" }), header: intl.formatMessage({ id: "column.roles" }),
cell: (info: any) => { cell: (info: any) => {
return JSON.stringify(info.getValue()); return <RolesFormatter roles={info.getValue()} />;
}, },
}), }),
columnHelper.display({ columnHelper.display({
@@ -95,7 +103,14 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
{currentUserId !== info.row.original.id ? ( {currentUserId !== info.row.original.id ? (
<> <>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#"> <a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDeleteUser?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
{intl.formatMessage({ id: "action.delete" })} {intl.formatMessage({ id: "action.delete" })}
</a> </a>
@@ -110,7 +125,7 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
}, },
}), }),
], ],
[columnHelper, currentUserId, onEditUser], [columnHelper, currentUserId, onEditUser, onDeleteUser],
); );
const tableInstance = useReactTable<User>({ const tableInstance = useReactTable<User>({
@@ -124,5 +139,10 @@ export default function Table({ data, isFetching, currentUserId, onEditUser }: P
enableSortingRemoval: false, enableSortingRemoval: false,
}); });
return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />; return (
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} />}
/>
);
} }

View File

@@ -1,14 +1,17 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteUser } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useUser, useUsers } from "src/hooks"; import { useUser, useUsers } from "src/hooks";
import { intl } from "src/locale"; import { intl } from "src/locale";
import { UserModal } from "src/modals"; import { DeleteConfirmModal, UserModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const [editUserId, setEditUserId] = useState(0); const [editUserId, setEditUserId] = useState(0 as number | "new");
const [deleteUserId, setDeleteUserId] = useState(0);
const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]); const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
const { data: currentUser } = useUser("me"); const { data: currentUser } = useUser("me");
@@ -20,6 +23,11 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteUser(deleteUserId);
showSuccess(intl.formatMessage({ id: "notification.user-deleted" }));
};
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-orange" /> <div className="card-status-top bg-orange" />
@@ -42,7 +50,7 @@ export default function TableWrapper() {
autoComplete="off" autoComplete="off"
/> />
</div> </div>
<Button size="sm" className="btn-orange"> <Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
{intl.formatMessage({ id: "users.add" })} {intl.formatMessage({ id: "users.add" })}
</Button> </Button>
</div> </div>
@@ -54,8 +62,20 @@ export default function TableWrapper() {
isFetching={isFetching} isFetching={isFetching}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
onEditUser={(id: number) => setEditUserId(id)} onEditUser={(id: number) => setEditUserId(id)}
onDeleteUser={(id: number) => setDeleteUserId(id)}
onNewUser={() => setEditUserId("new")}
/> />
{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null} {editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
{deleteUserId ? (
<DeleteConfirmModal
title={intl.formatMessage({ id: "user.delete.title" })}
onConfirm={handleDelete}
onClose={() => setDeleteUserId(0)}
invalidations={[["users"], ["user", deleteUserId]]}
>
{intl.formatMessage({ id: "user.delete.content" })}
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -3,7 +3,7 @@ import TableWrapper from "./TableWrapper";
const Users = () => { const Users = () => {
return ( return (
<HasPermission permission="admin" type="manage"> <HasPermission permission="admin" type="manage" pageLoading loadingNoLogo>
<TableWrapper /> <TableWrapper />
</HasPermission> </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" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.5.1.tgz#ba774c614be0f016da105c858e7159eae8e7687b"
integrity sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow== 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: convert-source-map@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" 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" cookie "^1.0.1"
set-cookie-parser "^2.6.0" 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: react-transition-group@^4.4.5:
version "4.4.5" version "4.4.5"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1"