فهرست منبع

Use a modal manager

Jamie Curnow 2 ماه پیش
والد
کامیت
7af01d0fc7
32فایلهای تغییر یافته به همراه291 افزوده شده و 251 حذف شده
  1. 1 0
      frontend/package.json
  2. 4 1
      frontend/src/App.tsx
  3. 3 10
      frontend/src/components/SiteHeader.tsx
  4. 5 1
      frontend/src/components/Table/Formatter/EventFormatter.tsx
  5. 3 0
      frontend/src/locale/lang/en.json
  6. 9 0
      frontend/src/locale/src/en.json
  7. 13 7
      frontend/src/modals/AccessListModal.tsx
  8. 15 9
      frontend/src/modals/ChangePasswordModal.tsx
  9. 13 7
      frontend/src/modals/DeadHostModal.tsx
  10. 16 7
      frontend/src/modals/DeleteConfirmModal.tsx
  11. 12 6
      frontend/src/modals/EventDetailsModal.tsx
  12. 16 10
      frontend/src/modals/PermissionsModal.tsx
  13. 13 7
      frontend/src/modals/ProxyHostModal.tsx
  14. 13 7
      frontend/src/modals/RedirectionHostModal.tsx
  15. 17 11
      frontend/src/modals/SetPasswordModal.tsx
  16. 13 7
      frontend/src/modals/StreamModal.tsx
  17. 16 10
      frontend/src/modals/UserModal.tsx
  18. 1 1
      frontend/src/pages/Access/Empty.tsx
  19. 16 28
      frontend/src/pages/Access/TableWrapper.tsx
  20. 2 5
      frontend/src/pages/AuditLog/TableWrapper.tsx
  21. 1 1
      frontend/src/pages/Certificates/Empty.tsx
  22. 1 1
      frontend/src/pages/Nginx/DeadHosts/Empty.tsx
  23. 14 20
      frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx
  24. 1 1
      frontend/src/pages/Nginx/ProxyHosts/Empty.tsx
  25. 15 20
      frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx
  26. 1 1
      frontend/src/pages/Nginx/RedirectionHosts/Empty.tsx
  27. 18 20
      frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx
  28. 1 1
      frontend/src/pages/Nginx/Streams/Empty.tsx
  29. 15 20
      frontend/src/pages/Nginx/Streams/TableWrapper.tsx
  30. 2 2
      frontend/src/pages/Users/Table.tsx
  31. 16 30
      frontend/src/pages/Users/TableWrapper.tsx
  32. 5 0
      frontend/yarn.lock

+ 1 - 0
frontend/package.json

@@ -24,6 +24,7 @@
 		"classnames": "^2.5.1",
 		"country-flag-icons": "^1.5.20",
 		"date-fns": "^4.1.0",
+		"ez-modal-react": "^1.0.5",
 		"formik": "^2.4.6",
 		"generate-password-browser": "^1.1.0",
 		"humps": "^2.0.1",

+ 4 - 1
frontend/src/App.tsx

@@ -1,5 +1,6 @@
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+import EasyModal from "ez-modal-react";
 import { RawIntlProvider } from "react-intl";
 import { ToastContainer } from "react-toastify";
 import { AuthProvider, LocaleProvider, ThemeProvider } from "src/context";
@@ -16,7 +17,9 @@ function App() {
 				<ThemeProvider>
 					<QueryClientProvider client={queryClient}>
 						<AuthProvider>
-							<Router />
+							<EasyModal.Provider>
+								<Router />
+							</EasyModal.Provider>
 							<ToastContainer
 								position="top-right"
 								autoClose={5000}

+ 3 - 10
frontend/src/components/SiteHeader.tsx

@@ -1,18 +1,15 @@
 import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
-import { useState } from "react";
 import { LocalePicker, ThemeSwitcher } from "src/components";
 import { useAuthState } from "src/context";
 import { useUser } from "src/hooks";
 import { T } from "src/locale";
-import { ChangePasswordModal, UserModal } from "src/modals";
+import { showChangePasswordModal, showUserModal } from "src/modals";
 import styles from "./SiteHeader.module.css";
 
 export function SiteHeader() {
 	const { data: currentUser } = useUser("me");
 	const isAdmin = currentUser?.roles.includes("admin");
 	const { logout } = useAuthState();
-	const [showProfileEdit, setShowProfileEdit] = useState(false);
-	const [showChangePassword, setShowChangePassword] = useState(false);
 
 	return (
 		<header className="navbar navbar-expand-md d-print-none">
@@ -76,7 +73,7 @@ export function SiteHeader() {
 									className="dropdown-item"
 									onClick={(e) => {
 										e.preventDefault();
-										setShowProfileEdit(true);
+										showUserModal("me");
 									}}
 								>
 									<IconUser width={18} />
@@ -87,7 +84,7 @@ export function SiteHeader() {
 									className="dropdown-item"
 									onClick={(e) => {
 										e.preventDefault();
-										setShowChangePassword(true);
+										showChangePasswordModal("me");
 									}}
 								>
 									<IconLock width={18} />
@@ -110,10 +107,6 @@ export function SiteHeader() {
 					</div>
 				</div>
 			</div>
-			{showProfileEdit ? <UserModal userId="me" onClose={() => setShowProfileEdit(false)} /> : null}
-			{showChangePassword ? (
-				<ChangePasswordModal userId="me" onClose={() => setShowChangePassword(false)} />
-			) : null}
 		</header>
 	);
 }

+ 5 - 1
frontend/src/components/Table/Formatter/EventFormatter.tsx

@@ -1,9 +1,10 @@
-import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconUser } from "@tabler/icons-react";
+import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconUser } from "@tabler/icons-react";
 import type { AuditLog } from "src/api/backend";
 import { DateTimeFormat, T } from "src/locale";
 
 const getEventValue = (event: AuditLog) => {
 	switch (event.objectType) {
+		case "access-list":
 		case "user":
 			return event.meta?.name;
 		case "proxy-host":
@@ -47,6 +48,9 @@ const getIcon = (row: AuditLog) => {
 		case "stream":
 			ico = <IconDisc size={16} className={c} />;
 			break;
+		case "access-list":
+			ico = <IconLock size={16} className={c} />;
+			break;
 	}
 
 	return ico;

+ 3 - 0
frontend/src/locale/lang/en.json

@@ -85,6 +85,7 @@
   "error.max-domains": "Too many domains, max is {max}",
   "error.passwords-must-match": "Passwords must match",
   "error.required": "This is required",
+  "event.created-access-list": "Created Access List",
   "event.created-dead-host": "Created 404 Host",
   "event.created-redirection-host": "Created Redirection Host",
   "event.created-stream": "Created Stream",
@@ -127,6 +128,7 @@
   "notification.host-deleted": "Host has been deleted",
   "notification.host-disabled": "Host has been disabled",
   "notification.host-enabled": "Host has been enabled",
+  "notification.proxy-host-saved": "Proxy Host has been saved",
   "notification.redirection-host-saved": "Redirection Host has been saved",
   "notification.stream-deleted": "Stream has been deleted",
   "notification.stream-disabled": "Stream has been disabled",
@@ -148,6 +150,7 @@
   "permissions.visibility.all": "All Items",
   "permissions.visibility.title": "Item Visibility",
   "permissions.visibility.user": "Created Items Only",
+  "proxy-host.edit": "Edit Proxy Host",
   "proxy-host.forward-host": "Forward Hostname / IP",
   "proxy-host.new": "New Proxy Host",
   "proxy-hosts.actions-title": "Proxy Host #{id}",

+ 9 - 0
frontend/src/locale/src/en.json

@@ -257,6 +257,9 @@
 	"error.required": {
 		"defaultMessage": "This is required"
 	},
+	"event.created-access-list": {
+		"defaultMessage": "Created Access List"
+	},
 	"event.created-dead-host": {
 		"defaultMessage": "Created 404 Host"
 	},
@@ -383,6 +386,9 @@
 	"notification.host-enabled": {
 		"defaultMessage": "Host has been enabled"
 	},
+	"notification.proxy-host-saved": {
+		"defaultMessage": "Proxy Host has been saved"
+	},
 	"notification.redirection-host-saved": {
 		"defaultMessage": "Redirection Host has been saved"
 	},
@@ -446,6 +452,9 @@
 	"permissions.visibility.user": {
 		"defaultMessage": "Created Items Only"
 	},
+	"proxy-host.edit": {
+		"defaultMessage": "Edit Proxy Host"
+	},
 	"proxy-host.forward-host": {
 		"defaultMessage": "Forward Hostname / IP"
 	},

+ 13 - 7
frontend/src/modals/AccessListModal.tsx

@@ -1,4 +1,5 @@
 import cn from "classnames";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -10,11 +11,14 @@ import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
-interface Props {
+const showAccessListModal = (id: number | "new") => {
+	EasyModal.show(AccessListModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number | "new";
-	onClose: () => void;
 }
-export function AccessListModal({ id, onClose }: Props) {
+const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useAccessList(id, ["items", "clients"]);
 	const { mutate: setAccessList } = useSetAccessList();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
@@ -69,7 +73,7 @@ export function AccessListModal({ id, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.access-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -82,7 +86,7 @@ export function AccessListModal({ id, onClose }: Props) {
 	const toggleEnabled = cn(toggleClasses, "bg-cyan");
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -263,7 +267,7 @@ export function AccessListModal({ id, onClose }: Props) {
 								</div>
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -283,4 +287,6 @@ export function AccessListModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showAccessListModal };

+ 15 - 9
frontend/src/modals/ChangePasswordModal.tsx

@@ -1,3 +1,4 @@
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -7,11 +8,14 @@ import { Button } from "src/components";
 import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 
-interface Props {
-	userId: number | "me";
-	onClose: () => void;
+const showChangePasswordModal = (id: number | "me") => {
+	EasyModal.show(ChangePasswordModal, { id });
+};
+
+interface Props extends InnerModalProps {
+	id: number | "me";
 }
-export function ChangePasswordModal({ userId, onClose }: Props) {
+const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const [error, setError] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
@@ -27,8 +31,8 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 		setError(null);
 
 		try {
-			await updateAuth(userId, values.new, values.current);
-			onClose();
+			await updateAuth(id, values.new, values.current);
+			remove();
 		} catch (err: any) {
 			setError(<T id={err.message} />);
 		}
@@ -37,7 +41,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			<Formik
 				initialValues={
 					{
@@ -142,7 +146,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 							</div>
 						</Modal.Body>
 						<Modal.Footer>
-							<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+							<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 								<T id="cancel" />
 							</Button>
 							<Button
@@ -161,4 +165,6 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 			</Formik>
 		</Modal>
 	);
-}
+});
+
+export { showChangePasswordModal };

+ 13 - 7
frontend/src/modals/DeadHostModal.tsx

@@ -1,4 +1,5 @@
 import { IconSettings } from "@tabler/icons-react";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -15,11 +16,14 @@ import { useDeadHost, useSetDeadHost } from "src/hooks";
 import { intl, T } from "src/locale";
 import { showSuccess } from "src/notifications";
 
-interface Props {
+const showDeadHostModal = (id: number | "new") => {
+	EasyModal.show(DeadHostModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number | "new";
-	onClose: () => void;
 }
-export function DeadHostModal({ id, onClose }: Props) {
+const DeadHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useDeadHost(id);
 	const { mutate: setDeadHost } = useSetDeadHost();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
@@ -39,7 +43,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -49,7 +53,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -145,7 +149,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 								</div>
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -165,4 +169,6 @@ export function DeadHostModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showDeadHostModal };

+ 16 - 7
frontend/src/modals/DeleteConfirmModal.tsx

@@ -1,18 +1,25 @@
 import { useQueryClient } from "@tanstack/react-query";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button } from "src/components";
 import { T } from "src/locale";
 
-interface Props {
+interface ShowProps {
 	title: string;
 	children: ReactNode;
 	onConfirm: () => Promise<void> | void;
-	onClose: () => void;
 	invalidations?: any[];
 }
-export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
+
+interface Props extends InnerModalProps, ShowProps {}
+
+const showDeleteConfirmModal = (props: ShowProps) => {
+	EasyModal.show(DeleteConfirmModal, props);
+};
+
+const DeleteConfirmModal = EasyModal.create(({ title, children, onConfirm, invalidations, visible, remove }: Props) => {
 	const queryClient = useQueryClient();
 	const [error, setError] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
@@ -23,7 +30,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 		setError(null);
 		try {
 			await onConfirm();
-			onClose();
+			remove();
 			// invalidate caches as requested
 			invalidations?.forEach((inv) => {
 				queryClient.invalidateQueries({ queryKey: inv });
@@ -35,7 +42,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			<Modal.Header closeButton>
 				<Modal.Title>
 					<T id={title} />
@@ -48,7 +55,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 				{children}
 			</Modal.Body>
 			<Modal.Footer>
-				<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+				<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 					<T id="cancel" />
 				</Button>
 				<Button
@@ -65,4 +72,6 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 			</Modal.Footer>
 		</Modal>
 	);
-}
+});
+
+export { showDeleteConfirmModal };

+ 12 - 6
frontend/src/modals/EventDetailsModal.tsx

@@ -1,18 +1,22 @@
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components";
 import { useAuditLog } from "src/hooks";
 import { T } from "src/locale";
 
-interface Props {
+const showEventDetailsModal = (id: number) => {
+	EasyModal.show(EventDetailsModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number;
-	onClose: () => void;
 }
-export function EventDetailsModal({ id, onClose }: Props) {
+const EventDetailsModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useAuditLog(id);
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -41,7 +45,7 @@ export function EventDetailsModal({ id, onClose }: Props) {
 						</div>
 					</Modal.Body>
 					<Modal.Footer>
-						<Button data-bs-dismiss="modal" onClick={onClose}>
+						<Button data-bs-dismiss="modal" onClick={remove}>
 							<T id="close" />
 						</Button>
 					</Modal.Footer>
@@ -49,4 +53,6 @@ export function EventDetailsModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showEventDetailsModal };

+ 16 - 10
frontend/src/modals/PermissionsModal.tsx

@@ -1,5 +1,6 @@
 import { useQueryClient } from "@tanstack/react-query";
 import cn from "classnames";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -9,14 +10,17 @@ import { Button, Loading } from "src/components";
 import { useUser } from "src/hooks";
 import { T } from "src/locale";
 
-interface Props {
-	userId: number;
-	onClose: () => void;
+const showPermissionsModal = (id: number) => {
+	EasyModal.show(PermissionsModal, { id });
+};
+
+interface Props extends InnerModalProps {
+	id: number;
 }
-export function PermissionsModal({ userId, onClose }: Props) {
+const PermissionsModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const queryClient = useQueryClient();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
-	const { data, isLoading, error } = useUser(userId);
+	const { data, isLoading, error } = useUser(id);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -24,8 +28,8 @@ export function PermissionsModal({ userId, onClose }: Props) {
 		setIsSubmitting(true);
 		setErrorMsg(null);
 		try {
-			await setPermissions(userId, values);
-			onClose();
+			await setPermissions(id, values);
+			remove();
 			queryClient.invalidateQueries({ queryKey: ["users"] });
 			queryClient.invalidateQueries({ queryKey: ["user"] });
 		} catch (err: any) {
@@ -86,7 +90,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 	const isAdmin = data?.roles.indexOf("admin") !== -1;
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -216,7 +220,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 								)}
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -236,4 +240,6 @@ export function PermissionsModal({ userId, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showPermissionsModal };

+ 13 - 7
frontend/src/modals/ProxyHostModal.tsx

@@ -1,5 +1,6 @@
 import { IconSettings } from "@tabler/icons-react";
 import cn from "classnames";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -18,11 +19,14 @@ import { intl, T } from "src/locale";
 import { validateNumber, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
-interface Props {
+const showProxyHostModal = (id: number | "new") => {
+	EasyModal.show(ProxyHostModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number | "new";
-	onClose: () => void;
 }
-export function ProxyHostModal({ id, onClose }: Props) {
+const ProxyHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useProxyHost(id);
 	const { mutate: setProxyHost } = useSetProxyHost();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
@@ -42,7 +46,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.proxy-host-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -52,7 +56,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -341,7 +345,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 								</div>
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -361,4 +365,6 @@ export function ProxyHostModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showProxyHostModal };

+ 13 - 7
frontend/src/modals/RedirectionHostModal.tsx

@@ -1,5 +1,6 @@
 import { IconSettings } from "@tabler/icons-react";
 import cn from "classnames";
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -17,11 +18,14 @@ import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
-interface Props {
+const showRedirectionHostModal = (id: number | "new") => {
+	EasyModal.show(RedirectionHostModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number | "new";
-	onClose: () => void;
 }
-export function RedirectionHostModal({ id, onClose }: Props) {
+const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useRedirectionHost(id);
 	const { mutate: setRedirectionHost } = useSetRedirectionHost();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
@@ -41,7 +45,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.redirection-host-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -51,7 +55,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -275,7 +279,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 								</div>
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -295,4 +299,6 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showRedirectionHostModal };

+ 17 - 11
frontend/src/modals/SetPasswordModal.tsx

@@ -1,3 +1,4 @@
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { generate } from "generate-password-browser";
 import { type ReactNode, useState } from "react";
@@ -8,21 +9,24 @@ import { Button } from "src/components";
 import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 
-interface Props {
-	userId: number;
-	onClose: () => void;
+const showSetPasswordModal = (id: number) => {
+	EasyModal.show(SetPasswordModal, { id });
+};
+
+interface Props extends InnerModalProps {
+	id: number;
 }
-export function SetPasswordModal({ userId, onClose }: Props) {
+const SetPasswordModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const [error, setError] = useState<ReactNode | null>(null);
 	const [showPassword, setShowPassword] = useState(false);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
-	const _onSubmit = async (values: any, { setSubmitting }: any) => {
+	const onSubmit = async (values: any, { setSubmitting }: any) => {
 		if (isSubmitting) return;
 		setError(null);
 		try {
-			await updateAuth(userId, values.new);
-			onClose();
+			await updateAuth(id, values.new);
+			remove();
 		} catch (err: any) {
 			setError(<T id={err.message} />);
 		}
@@ -31,14 +35,14 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			<Formik
 				initialValues={
 					{
 						new: "",
 					} as any
 				}
-				onSubmit={_onSubmit}
+				onSubmit={onSubmit}
 			>
 				{() => (
 					<Form>
@@ -110,7 +114,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 							</div>
 						</Modal.Body>
 						<Modal.Footer>
-							<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+							<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 								<T id="cancel" />
 							</Button>
 							<Button
@@ -129,4 +133,6 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 			</Formik>
 		</Modal>
 	);
-}
+});
+
+export { showSetPasswordModal };

+ 13 - 7
frontend/src/modals/StreamModal.tsx

@@ -1,3 +1,4 @@
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -8,11 +9,14 @@ import { intl, T } from "src/locale";
 import { validateNumber, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
-interface Props {
+const showStreamModal = (id: number | "new") => {
+	EasyModal.show(StreamModal, { id });
+};
+
+interface Props extends InnerModalProps {
 	id: number | "new";
-	onClose: () => void;
 }
-export function StreamModal({ id, onClose }: Props) {
+const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {
 	const { data, isLoading, error } = useStream(id);
 	const { mutate: setStream } = useSetStream();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
@@ -32,7 +36,7 @@ export function StreamModal({ id, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.stream-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -42,7 +46,7 @@ export function StreamModal({ id, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -296,7 +300,7 @@ export function StreamModal({ id, onClose }: Props) {
 								</div>
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -316,4 +320,6 @@ export function StreamModal({ id, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showStreamModal };

+ 16 - 10
frontend/src/modals/UserModal.tsx

@@ -1,3 +1,4 @@
+import EasyModal, { type InnerModalProps } from "ez-modal-react";
 import { Field, Form, Formik } from "formik";
 import { useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -8,12 +9,15 @@ import { intl, T } from "src/locale";
 import { validateEmail, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
-interface Props {
-	userId: number | "me" | "new";
-	onClose: () => void;
+const showUserModal = (id: number | "me" | "new") => {
+	EasyModal.show(UserModal, { id });
+};
+
+interface Props extends InnerModalProps {
+	id: number | "me" | "new";
 }
-export function UserModal({ userId, onClose }: Props) {
-	const { data, isLoading, error } = useUser(userId);
+const UserModal = EasyModal.create(({ id, visible, remove }: Props) => {
+	const { data, isLoading, error } = useUser(id);
 	const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
 	const { mutate: setUser } = useSetUser();
 	const [errorMsg, setErrorMsg] = useState<string | null>(null);
@@ -25,7 +29,7 @@ export function UserModal({ userId, onClose }: Props) {
 		setErrorMsg(null);
 
 		const { ...payload } = {
-			id: userId === "new" ? undefined : userId,
+			id: id === "new" ? undefined : id,
 			roles: [],
 			...values,
 		};
@@ -45,7 +49,7 @@ export function UserModal({ userId, onClose }: Props) {
 			onError: (err: any) => setErrorMsg(err.message),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
-				onClose();
+				remove();
 			},
 			onSettled: () => {
 				setIsSubmitting(false);
@@ -55,7 +59,7 @@ export function UserModal({ userId, onClose }: Props) {
 	};
 
 	return (
-		<Modal show onHide={onClose} animation={false}>
+		<Modal show={visible} onHide={remove}>
 			{!isLoading && error && (
 				<Alert variant="danger" className="m-3">
 					{error?.message || "Unknown error"}
@@ -218,7 +222,7 @@ export function UserModal({ userId, onClose }: Props) {
 								) : null}
 							</Modal.Body>
 							<Modal.Footer>
-								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+								<Button data-bs-dismiss="modal" onClick={remove} disabled={isSubmitting}>
 									<T id="cancel" />
 								</Button>
 								<Button
@@ -238,4 +242,6 @@ export function UserModal({ userId, onClose }: Props) {
 			)}
 		</Modal>
 	);
-}
+});
+
+export { showUserModal };

+ 1 - 1
frontend/src/pages/Access/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 16 - 28
frontend/src/pages/Access/TableWrapper.tsx

@@ -5,14 +5,12 @@ import { deleteAccessList } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useAccessLists } from "src/hooks";
 import { intl, T } from "src/locale";
-import { AccessListModal, DeleteConfirmModal } from "src/modals";
+import { showAccessListModal, showDeleteConfirmModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const [search, setSearch] = useState("");
-	const [editId, setEditId] = useState(0 as number | "new");
-	const [deleteId, setDeleteId] = useState(0);
 	const { isFetching, isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
 
 	if (isLoading) {
@@ -23,21 +21,15 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteAccessList(deleteId);
+	const handleDelete = async (id: number) => {
+		await deleteAccessList(id);
 		showSuccess(intl.formatMessage({ id: "notification.access-deleted" }));
 	};
 
 	let filtered = null;
 	if (search && data) {
-		filtered = data?.filter((_item) => {
-			return true;
-			// TODO
-			// return (
-			// 	`${item.incomingPort}`.includes(search) ||
-			// 	`${item.forwardingPort}`.includes(search) ||
-			// 	item.forwardingHost.includes(search)
-			// );
+		filtered = data?.filter((item) => {
+			return item.name.toLowerCase().includes(search);
 		});
 	} else if (search !== "") {
 		// this can happen if someone deletes the last item while searching
@@ -70,7 +62,7 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-									<Button size="sm" className="btn-cyan" onClick={() => setEditId("new")}>
+									<Button size="sm" className="btn-cyan" onClick={() => showAccessListModal("new")}>
 										<T id="access.add" />
 									</Button>
 								</div>
@@ -82,21 +74,17 @@ export default function TableWrapper() {
 					data={filtered ?? data ?? []}
 					isFetching={isFetching}
 					isFiltered={!!filtered}
-					onEdit={(id: number) => setEditId(id)}
-					onDelete={(id: number) => setDeleteId(id)}
-					onNew={() => setEditId("new")}
+					onEdit={(id: number) => showAccessListModal(id)}
+					onDelete={(id: number) =>
+						showDeleteConfirmModal({
+							title: "access.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["access-lists"], ["access-list", id]],
+							children: <T id="access.delete.content" />,
+						})
+					}
+					onNew={() => showAccessListModal("new")}
 				/>
-				{editId ? <AccessListModal id={editId} onClose={() => setEditId(0)} /> : null}
-				{deleteId ? (
-					<DeleteConfirmModal
-						title="access.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteId(0)}
-						invalidations={[["access-lists"], ["access-list", deleteId]]}
-					>
-						<T id="access.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
 			</div>
 		</div>
 	);

+ 2 - 5
frontend/src/pages/AuditLog/TableWrapper.tsx

@@ -1,13 +1,11 @@
-import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
 import { LoadingPage } from "src/components";
 import { useAuditLogs } from "src/hooks";
 import { T } from "src/locale";
-import { EventDetailsModal } from "src/modals";
+import { showEventDetailsModal } from "src/modals";
 import Table from "./Table";
 
 export default function TableWrapper() {
-	const [eventId, setEventId] = useState(0);
 	const { isFetching, isLoading, isError, error, data } = useAuditLogs(["user"]);
 
 	if (isLoading) {
@@ -31,8 +29,7 @@ export default function TableWrapper() {
 						</div>
 					</div>
 				</div>
-				<Table data={data ?? []} isFetching={isFetching} onSelectItem={setEventId} />
-				{eventId ? <EventDetailsModal id={eventId} onClose={() => setEventId(0)} /> : null}
+				<Table data={data ?? []} isFetching={isFetching} onSelectItem={showEventDetailsModal} />
 			</div>
 		</div>
 	);

+ 1 - 1
frontend/src/pages/Certificates/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, onNewCustom, isFiltered }:
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 1 - 1
frontend/src/pages/Nginx/DeadHosts/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 14 - 20
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -6,15 +6,13 @@ import { deleteDeadHost, toggleDeadHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useDeadHosts } from "src/hooks";
 import { intl, T } from "src/locale";
-import { DeadHostModal, DeleteConfirmModal } from "src/modals";
+import { showDeadHostModal, showDeleteConfirmModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const queryClient = useQueryClient();
 	const [search, setSearch] = useState("");
-	const [deleteId, setDeleteId] = useState(0);
-	const [editId, setEditId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]);
 
 	if (isLoading) {
@@ -25,8 +23,8 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteDeadHost(deleteId);
+	const handleDelete = async (id: number) => {
+		await deleteDeadHost(id);
 		showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
 	};
 
@@ -73,7 +71,7 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-									<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
+									<Button size="sm" className="btn-red" onClick={() => showDeadHostModal("new")}>
 										<T id="dead-hosts.add" />
 									</Button>
 								</div>
@@ -85,22 +83,18 @@ export default function TableWrapper() {
 					data={filtered ?? data ?? []}
 					isFiltered={!!search}
 					isFetching={isFetching}
-					onEdit={(id: number) => setEditId(id)}
-					onDelete={(id: number) => setDeleteId(id)}
+					onEdit={(id: number) => showDeadHostModal(id)}
+					onDelete={(id: number) =>
+						showDeleteConfirmModal({
+							title: "dead-host.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["dead-hosts"], ["dead-host", id]],
+							children: <T id="dead-host.delete.content" />,
+						})
+					}
 					onDisableToggle={handleDisableToggle}
-					onNew={() => setEditId("new")}
+					onNew={() => showDeadHostModal("new")}
 				/>
-				{editId ? <DeadHostModal id={editId} onClose={() => setEditId(0)} /> : null}
-				{deleteId ? (
-					<DeleteConfirmModal
-						title="dead-host.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteId(0)}
-						invalidations={[["dead-hosts"], ["dead-host", deleteId]]}
-					>
-						<T id="dead-host.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
 			</div>
 		</div>
 	);

+ 1 - 1
frontend/src/pages/Nginx/ProxyHosts/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 15 - 20
frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx

@@ -6,15 +6,13 @@ import { deleteProxyHost, toggleProxyHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useProxyHosts } from "src/hooks";
 import { intl, T } from "src/locale";
-import { DeleteConfirmModal, ProxyHostModal } from "src/modals";
+import { showDeleteConfirmModal, showProxyHostModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const queryClient = useQueryClient();
 	const [search, setSearch] = useState("");
-	const [deleteId, setDeleteId] = useState(0);
-	const [editId, setEditId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]);
 
 	if (isLoading) {
@@ -25,8 +23,8 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteProxyHost(deleteId);
+	const handleDelete = async (id: number) => {
+		await deleteProxyHost(id);
 		showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
 	};
 
@@ -74,9 +72,10 @@ export default function TableWrapper() {
 											type="text"
 											className="form-control form-control-sm"
 											autoComplete="off"
+											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-									<Button size="sm" className="btn-lime">
+									<Button size="sm" className="btn-lime" onClick={() => showProxyHostModal("new")}>
 										<T id="proxy-hosts.add" />
 									</Button>
 								</div>
@@ -88,22 +87,18 @@ export default function TableWrapper() {
 					data={filtered ?? data ?? []}
 					isFiltered={!!search}
 					isFetching={isFetching}
-					onEdit={(id: number) => setEditId(id)}
-					onDelete={(id: number) => setDeleteId(id)}
+					onEdit={(id: number) => showProxyHostModal(id)}
+					onDelete={(id: number) =>
+						showDeleteConfirmModal({
+							title: "proxy-host.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["proxy-hosts"], ["proxy-host", id]],
+							children: <T id="proxy-host.delete.content" />,
+						})
+					}
 					onDisableToggle={handleDisableToggle}
-					onNew={() => setEditId("new")}
+					onNew={() => showProxyHostModal("new")}
 				/>
-				{editId ? <ProxyHostModal id={editId} onClose={() => setEditId(0)} /> : null}
-				{deleteId ? (
-					<DeleteConfirmModal
-						title="proxy-host.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteId(0)}
-						invalidations={[["proxy-hosts"], ["proxy-host", deleteId]]}
-					>
-						<T id="proxy-host.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
 			</div>
 		</div>
 	);

+ 1 - 1
frontend/src/pages/Nginx/RedirectionHosts/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 18 - 20
frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx

@@ -6,15 +6,13 @@ import { deleteRedirectionHost, toggleRedirectionHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useRedirectionHosts } from "src/hooks";
 import { intl, T } from "src/locale";
-import { DeleteConfirmModal, RedirectionHostModal } from "src/modals";
+import { showDeleteConfirmModal, showRedirectionHostModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const queryClient = useQueryClient();
 	const [search, setSearch] = useState("");
-	const [deleteId, setDeleteId] = useState(0);
-	const [editId, setEditId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useRedirectionHosts(["owner", "certificate"]);
 
 	if (isLoading) {
@@ -25,8 +23,8 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteRedirectionHost(deleteId);
+	const handleDelete = async (id: number) => {
+		await deleteRedirectionHost(id);
 		showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
 	};
 
@@ -76,7 +74,11 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-									<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}>
+									<Button
+										size="sm"
+										className="btn-yellow"
+										onClick={() => showRedirectionHostModal("new")}
+									>
 										<T id="redirection-hosts.add" />
 									</Button>
 								</div>
@@ -88,22 +90,18 @@ export default function TableWrapper() {
 					data={filtered ?? data ?? []}
 					isFiltered={!!search}
 					isFetching={isFetching}
-					onEdit={(id: number) => setEditId(id)}
-					onDelete={(id: number) => setDeleteId(id)}
+					onEdit={(id: number) => showRedirectionHostModal(id)}
+					onDelete={(id: number) =>
+						showDeleteConfirmModal({
+							title: "redirection-host.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["redirection-hosts"], ["redirection-host", id]],
+							children: <T id="redirection-host.delete.content" />,
+						})
+					}
 					onDisableToggle={handleDisableToggle}
-					onNew={() => setEditId("new")}
+					onNew={() => showRedirectionHostModal("new")}
 				/>
-				{editId ? <RedirectionHostModal id={editId} onClose={() => setEditId(0)} /> : null}
-				{deleteId ? (
-					<DeleteConfirmModal
-						title="redirection-host.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteId(0)}
-						invalidations={[["redirection-hosts"], ["redirection-host", deleteId]]}
-					>
-						<T id="redirection-host.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
 			</div>
 		</div>
 	);

+ 1 - 1
frontend/src/pages/Nginx/Streams/Empty.tsx

@@ -14,7 +14,7 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 				<div className="text-center my-4">
 					{isFiltered ? (
 						<h2>
-							<T id="empty.search" />
+							<T id="empty-search" />
 						</h2>
 					) : (
 						<>

+ 15 - 20
frontend/src/pages/Nginx/Streams/TableWrapper.tsx

@@ -6,15 +6,14 @@ import { deleteStream, toggleStream } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useStreams } from "src/hooks";
 import { intl, T } from "src/locale";
-import { DeleteConfirmModal, StreamModal } from "src/modals";
+import { showDeleteConfirmModal, showStreamModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const queryClient = useQueryClient();
 	const [search, setSearch] = useState("");
-	const [editId, setEditId] = useState(0 as number | "new");
-	const [deleteId, setDeleteId] = useState(0);
+	const [_deleteId, _setDeleteIdd] = useState(0);
 	const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]);
 
 	if (isLoading) {
@@ -25,8 +24,8 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteStream(deleteId);
+	const handleDelete = async (id: number) => {
+		await deleteStream(id);
 		showSuccess(intl.formatMessage({ id: "notification.stream-deleted" }));
 	};
 
@@ -79,7 +78,7 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-									<Button size="sm" className="btn-blue" onClick={() => setEditId("new")}>
+									<Button size="sm" className="btn-blue" onClick={() => showStreamModal("new")}>
 										<T id="streams.add" />
 									</Button>
 								</div>
@@ -91,22 +90,18 @@ export default function TableWrapper() {
 					data={filtered ?? data ?? []}
 					isFetching={isFetching}
 					isFiltered={!!filtered}
-					onEdit={(id: number) => setEditId(id)}
-					onDelete={(id: number) => setDeleteId(id)}
+					onEdit={(id: number) => showStreamModal(id)}
+					onDelete={(id: number) =>
+						showDeleteConfirmModal({
+							title: "stream.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["streams"], ["stream", id]],
+							children: <T id="stream.delete.content" />,
+						})
+					}
 					onDisableToggle={handleDisableToggle}
-					onNew={() => setEditId("new")}
+					onNew={() => showStreamModal("new")}
 				/>
-				{editId ? <StreamModal id={editId} onClose={() => setEditId(0)} /> : null}
-				{deleteId ? (
-					<DeleteConfirmModal
-						title="stream.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteId(0)}
-						invalidations={[["streams"], ["stream", deleteId]]}
-					>
-						<T id="stream.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
 			</div>
 		</div>
 	);

+ 2 - 2
frontend/src/pages/Users/Table.tsx

@@ -87,7 +87,7 @@ export default function Table({
 				},
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">
@@ -112,7 +112,7 @@ export default function Table({
 									}}
 								>
 									<IconEdit size={16} />
-									<T id="users.edit" />
+									<T id="user.edit" />
 								</a>
 								{currentUserId !== info.row.original.id ? (
 									<>

+ 16 - 30
frontend/src/pages/Users/TableWrapper.tsx

@@ -6,17 +6,13 @@ import { deleteUser, toggleUser } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useUser, useUsers } from "src/hooks";
 import { intl, T } from "src/locale";
-import { DeleteConfirmModal, PermissionsModal, SetPasswordModal, UserModal } from "src/modals";
+import { showDeleteConfirmModal, showPermissionsModal, showSetPasswordModal, showUserModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
 	const queryClient = useQueryClient();
 	const [search, setSearch] = useState("");
-	const [editUserId, setEditUserId] = useState(0 as number | "new");
-	const [editUserPermissionsId, setEditUserPermissionsId] = useState(0);
-	const [editUserPasswordId, setEditUserPasswordId] = useState(0);
-	const [deleteUserId, setDeleteUserId] = useState(0);
 	const { isFetching, isLoading, isError, error, data } = useUsers(["permissions"]);
 	const { data: currentUser } = useUser("me");
 
@@ -28,8 +24,8 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
-	const handleDelete = async () => {
-		await deleteUser(deleteUserId);
+	const handleDelete = async (id: number) => {
+		await deleteUser(id);
 		showSuccess(intl.formatMessage({ id: "notification.user-deleted" }));
 	};
 
@@ -81,7 +77,7 @@ export default function TableWrapper() {
 										/>
 									</div>
 
-									<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
+									<Button size="sm" className="btn-orange" onClick={() => showUserModal("new")}>
 										<T id="users.add" />
 									</Button>
 								</div>
@@ -94,30 +90,20 @@ export default function TableWrapper() {
 					isFiltered={!!search}
 					isFetching={isFetching}
 					currentUserId={currentUser?.id}
-					onEditUser={(id: number) => setEditUserId(id)}
-					onEditPermissions={(id: number) => setEditUserPermissionsId(id)}
-					onSetPassword={(id: number) => setEditUserPasswordId(id)}
-					onDeleteUser={(id: number) => setDeleteUserId(id)}
+					onEditUser={(id: number) => showUserModal(id)}
+					onEditPermissions={(id: number) => showPermissionsModal(id)}
+					onSetPassword={(id: number) => showSetPasswordModal(id)}
+					onDeleteUser={(id: number) =>
+						showDeleteConfirmModal({
+							title: "user.delete.title",
+							onConfirm: () => handleDelete(id),
+							invalidations: [["users"], ["user", id]],
+							children: <T id="user.delete.content" />,
+						})
+					}
 					onDisableToggle={handleDisableToggle}
-					onNewUser={() => setEditUserId("new")}
+					onNewUser={() => showUserModal("new")}
 				/>
-				{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
-				{editUserPermissionsId ? (
-					<PermissionsModal userId={editUserPermissionsId} onClose={() => setEditUserPermissionsId(0)} />
-				) : null}
-				{deleteUserId ? (
-					<DeleteConfirmModal
-						title="user.delete.title"
-						onConfirm={handleDelete}
-						onClose={() => setDeleteUserId(0)}
-						invalidations={[["users"], ["user", deleteUserId]]}
-					>
-						<T id="user.delete.content" />
-					</DeleteConfirmModal>
-				) : null}
-				{editUserPasswordId ? (
-					<SetPasswordModal userId={editUserPasswordId} onClose={() => setEditUserPasswordId(0)} />
-				) : null}
 			</div>
 		</div>
 	);

+ 5 - 0
frontend/yarn.lock

@@ -1530,6 +1530,11 @@ extend@^3.0.0:
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
+ez-modal-react@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/ez-modal-react/-/ez-modal-react-1.0.5.tgz#38d36c5e31f54f6b7cb7afa0cc79a8d1190c2805"
+  integrity sha512-/A8yLK54tpmWCMkW8Pwqc2xxspmimGOOw/m+1Y+tNtUIheuDHhLynHP1Q0utciJEGDAK849aQcd+6DrJ88hggQ==
+
 fast-deep-equal@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"