diff --git a/frontend/src/components/Form/BasicAuthFields.tsx b/frontend/src/components/Form/BasicAuthFields.tsx
index d8d33d159..0d5548490 100644
--- a/frontend/src/components/Form/BasicAuthFields.tsx
+++ b/frontend/src/components/Form/BasicAuthFields.tsx
@@ -38,8 +38,7 @@ export function BasicAuthFields({ initialValues, name = "items" }: Props) {
};
const setFormField = (newValues: AccessListItem[]) => {
- const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== "");
- setFieldValue(name, filtered);
+ setFieldValue(name, newValues);
};
return (
diff --git a/frontend/src/components/Form/FormModal.drafts.test.tsx b/frontend/src/components/Form/FormModal.drafts.test.tsx
new file mode 100644
index 000000000..7e1e11633
--- /dev/null
+++ b/frontend/src/components/Form/FormModal.drafts.test.tsx
@@ -0,0 +1,63 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { Form } from "formik";
+import type { ReactNode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { BasicAuthFields } from "./BasicAuthFields";
+import { FormModal } from "./FormModal";
+import { LocationsFields } from "./LocationsFields";
+
+vi.mock("./AccessField", () => ({ AccessField: () => null }));
+
+const renderDraft = (initialValues: Record, children: ReactNode) => {
+ const onHide = vi.fn();
+ render(
+
+ {({ onHide: hide }) => (
+
+ )}
+ ,
+ );
+ return onHide;
+};
+
+describe("FormModal local drafts", () => {
+ beforeEach(() => {
+ vi.stubGlobal(
+ "confirm",
+ vi.fn(() => false),
+ );
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("protects a location draft without a path", () => {
+ const onHide = renderDraft({ locations: [] }, );
+ fireEvent.click(screen.getByRole("button", { name: "Add Location" }));
+ fireEvent.change(screen.getByPlaceholderText("eg: 10.0.0.1/path/"), { target: { value: "example.com" } });
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(window.confirm).toHaveBeenCalledOnce();
+ expect(onHide).not.toHaveBeenCalled();
+ });
+
+ it("protects a basic auth password draft without a username", () => {
+ const onHide = renderDraft({ items: [] }, );
+ fireEvent.change(document.querySelector('input[type="password"]') as Element, {
+ target: { value: "secret" },
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(window.confirm).toHaveBeenCalledOnce();
+ expect(onHide).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/components/Form/FormModal.test.tsx b/frontend/src/components/Form/FormModal.test.tsx
new file mode 100644
index 000000000..de4080596
--- /dev/null
+++ b/frontend/src/components/Form/FormModal.test.tsx
@@ -0,0 +1,82 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { Field, Form } from "formik";
+import Modal from "react-bootstrap/Modal";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { FormModal } from "./FormModal";
+
+const renderModal = (onHide = vi.fn(), preventHide = false) => {
+ render(
+
+ {({ onHide: hide }) => (
+
+ )}
+ ,
+ );
+ return onHide;
+};
+
+describe("FormModal", () => {
+ beforeEach(() => {
+ vi.stubGlobal("confirm", vi.fn());
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("dismisses a pristine form without confirmation", () => {
+ const onHide = renderModal();
+ const confirm = vi.spyOn(window, "confirm");
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it("keeps a dirty form open when dismissal is cancelled", () => {
+ const onHide = renderModal();
+ const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ fireEvent.click(document.querySelector(".modal") as Element);
+ fireEvent.keyDown(document, { key: "Escape", keyCode: 27 });
+
+ expect(confirm).toHaveBeenCalledTimes(4);
+ expect(onHide).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Name")).toHaveValue("example.com");
+ });
+
+ it("dismisses a dirty form after confirmation", () => {
+ const onHide = renderModal();
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(onHide).toHaveBeenCalledOnce();
+ });
+
+ it("does not offer to discard changes while submission is in progress", () => {
+ const onHide = renderModal(vi.fn(), true);
+ const confirm = vi.spyOn(window, "confirm");
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "example.com" } });
+
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+
+ expect(confirm).not.toHaveBeenCalled();
+ expect(onHide).not.toHaveBeenCalled();
+ expect(screen.getByLabelText("Name")).toHaveValue("example.com");
+ });
+});
diff --git a/frontend/src/components/Form/FormModal.tsx b/frontend/src/components/Form/FormModal.tsx
new file mode 100644
index 000000000..d9698521d
--- /dev/null
+++ b/frontend/src/components/Form/FormModal.tsx
@@ -0,0 +1,55 @@
+import { Formik, type FormikConfig, type FormikProps, type FormikValues } from "formik";
+import { type ReactNode, useEffect } from "react";
+import Modal from "react-bootstrap/Modal";
+import { intl } from "src/locale";
+
+const confirmDiscardChanges = (dirty: boolean) =>
+ !dirty ||
+ window.confirm(
+ intl.formatMessage({
+ id: "form.unsaved-changes",
+ defaultMessage: "You have unsaved changes. Are you sure you want to discard them?",
+ }),
+ );
+
+const FormDirtyState = ({ dirty, onChange }: { dirty: boolean; onChange: (dirty: boolean) => void }) => {
+ useEffect(() => {
+ onChange(dirty);
+ return () => onChange(false);
+ }, [dirty, onChange]);
+
+ return null;
+};
+
+interface FormModalProps extends Omit, "children"> {
+ show: boolean;
+ onHide: () => void;
+ preventHide?: boolean;
+ children: (props: FormikProps & { onHide: () => void }) => ReactNode;
+}
+
+const FormModal = ({
+ show,
+ onHide,
+ preventHide = false,
+ children,
+ ...formikProps
+}: FormModalProps) => (
+
+ {(formikProps) => {
+ const handleHide = () => {
+ if (!preventHide && confirmDiscardChanges(formikProps.dirty)) {
+ onHide();
+ }
+ };
+
+ return (
+
+ {children({ ...formikProps, onHide: handleHide })}
+
+ );
+ }}
+
+);
+
+export { confirmDiscardChanges, FormDirtyState, FormModal };
diff --git a/frontend/src/components/Form/LocationsFields.tsx b/frontend/src/components/Form/LocationsFields.tsx
index a0f64c239..0f8d83033 100644
--- a/frontend/src/components/Form/LocationsFields.tsx
+++ b/frontend/src/components/Form/LocationsFields.tsx
@@ -88,8 +88,10 @@ export function LocationsFields({ initialValues, name = "locations" }: Props) {
};
const setFormField = (newRows: Row[]) => {
- const filtered = newRows.map((r: Row) => r.value).filter((v: ProxyLocation) => v?.path?.trim() !== "");
- setFieldValue(name, filtered);
+ setFieldValue(
+ name,
+ newRows.map((r: Row) => r.value),
+ );
};
const forwardSummary = (item: ProxyLocation) => {
diff --git a/frontend/src/components/Form/index.ts b/frontend/src/components/Form/index.ts
index f218b2e33..8530d6f6d 100644
--- a/frontend/src/components/Form/index.ts
+++ b/frontend/src/components/Form/index.ts
@@ -3,6 +3,7 @@ export * from "./AccessField";
export * from "./BasicAuthFields";
export * from "./DNSProviderFields";
export * from "./DomainNamesField";
+export * from "./FormModal";
export * from "./LocationsFields";
export * from "./NginxConfigField";
export * from "./SSLCertificateField";
diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json
index 11d09ea71..14c1d5b44 100644
--- a/frontend/src/locale/src/en.json
+++ b/frontend/src/locale/src/en.json
@@ -437,6 +437,9 @@
"footer.github-fork": {
"defaultMessage": "Fork me on Github"
},
+ "form.unsaved-changes": {
+ "defaultMessage": "You have unsaved changes. Are you sure you want to discard them?"
+ },
"host.flags.block-exploits": {
"defaultMessage": "Block Common Exploits"
},
diff --git a/frontend/src/locale/src/et.json b/frontend/src/locale/src/et.json
index 316ede752..2301c5a46 100644
--- a/frontend/src/locale/src/et.json
+++ b/frontend/src/locale/src/et.json
@@ -437,6 +437,9 @@
"footer.github-fork": {
"defaultMessage": "Tee GitHubis fork"
},
+ "form.unsaved-changes": {
+ "defaultMessage": "Sul on salvestamata muudatusi. Kas oled kindel, et soovid need tühistada?"
+ },
"host.flags.block-exploits": {
"defaultMessage": "Blokeeri levinud ründed"
},
diff --git a/frontend/src/modals/AccessListModal.tsx b/frontend/src/modals/AccessListModal.tsx
index 79537f5cd..01b68d3d6 100644
--- a/frontend/src/modals/AccessListModal.tsx
+++ b/frontend/src/modals/AccessListModal.tsx
@@ -1,11 +1,12 @@
import cn from "classnames";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
-import { Field, Form, Formik } from "formik";
+import { Field, Form } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import type { AccessList, AccessListClient, AccessListItem } from "src/api/backend";
import { AccessClientFields, BasicAuthFields, Button, Loading } from "src/components";
+import { FormModal } from "src/components/Form";
import { useAccessList, useSetAccessList } from "src/hooks";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
@@ -25,13 +26,16 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = (values: any): string | null => {
+ const items = (values.items || []).filter((i: AccessListItem) => i.username?.trim() !== "");
+ const clients = (values.clients || []).filter((i: AccessListClient) => i.address?.trim() !== "");
+
// either Auths or Clients must be defined
- if (values.items?.length === 0 && values.clients?.length === 0) {
+ if (items.length === 0 && clients.length === 0) {
return intl.formatMessage({ id: "error.access.at-least-one" });
}
// ensure the items don't contain the same username twice
- const usernames = values.items.map((i: any) => i.username);
+ const usernames = items.map((i: AccessListItem) => i.username);
const uniqueUsernames = Array.from(new Set(usernames));
if (usernames.length !== uniqueUsernames.length) {
return intl.formatMessage({ id: "error.access.duplicate-usernames" });
@@ -58,10 +62,12 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => {
};
// Filter out "items" to only use the "username" and "password" fields
- payload.items = (values.items || []).map((i: AccessListItem) => ({
- username: i.username,
- password: i.password,
- }));
+ payload.items = (values.items || [])
+ .filter((i: AccessListItem) => i.username?.trim() !== "")
+ .map((i: AccessListItem) => ({
+ username: i.username,
+ password: i.password,
+ }));
// Filter out "clients" to only use the "directive" and "address" fields
payload.clients = (values.clients || []).map((i: AccessListClient) => ({
@@ -86,15 +92,22 @@ const AccessListModal = EasyModal.create(({ id, visible, remove }: Props) => {
const toggleEnabled = cn(toggleClasses, "bg-cyan");
return (
-
- {!isLoading && error && (
-
- {error?.message || "Unknown error"}
-
+ <>
+ {(isLoading || (error && !data)) && (
+
+ {!isLoading && error && (
+
+ {error?.message || "Unknown error"}
+
+ )}
+ {isLoading && }
+
)}
- {isLoading && }
{!isLoading && data && (
- {
}
onSubmit={onSubmit}
>
- {({ setFieldValue }: any) => (
+ {({ onHide, setFieldValue }: any) => (
)}
-
+
)}
-
+ >
);
});
diff --git a/frontend/src/modals/ChangePasswordModal.tsx b/frontend/src/modals/ChangePasswordModal.tsx
index 48221e281..7f1d9668d 100644
--- a/frontend/src/modals/ChangePasswordModal.tsx
+++ b/frontend/src/modals/ChangePasswordModal.tsx
@@ -1,10 +1,11 @@
import EasyModal, { type InnerModalProps } from "ez-modal-react";
-import { Field, Form, Formik } from "formik";
+import { Field, Form } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { updateAuth } from "src/api/backend";
import { Button } from "src/components";
+import { FormModal } from "src/components/Form";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
@@ -41,129 +42,130 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) =>
};
return (
-
-
- {() => (
-
- )}
-
-
+
+ {({ onHide }) => (
+
+ )}
+
);
});
diff --git a/frontend/src/modals/CustomCertificateModal.tsx b/frontend/src/modals/CustomCertificateModal.tsx
index deab1c523..c373a8d36 100644
--- a/frontend/src/modals/CustomCertificateModal.tsx
+++ b/frontend/src/modals/CustomCertificateModal.tsx
@@ -1,12 +1,13 @@
import { IconAlertTriangle } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import EasyModal, { type InnerModalProps } from "ez-modal-react";
-import { Field, Form, Formik } from "formik";
+import { Field, Form } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { type Certificate, createCertificate, uploadCertificate, validateCertificate } from "src/api/backend";
import { Button } from "src/components";
+import { FormModal } from "src/components/Form";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showObjectSuccess } from "src/notifications";
@@ -57,175 +58,176 @@ const CustomCertificateModal = EasyModal.create(({ visible, remove }: InnerModal
};
return (
-
-
- {() => (
-
)}
-
+
)}
-
+ >
);
});
diff --git a/frontend/src/modals/SetPasswordModal.tsx b/frontend/src/modals/SetPasswordModal.tsx
index ef6d99beb..665c00266 100644
--- a/frontend/src/modals/SetPasswordModal.tsx
+++ b/frontend/src/modals/SetPasswordModal.tsx
@@ -1,11 +1,12 @@
import EasyModal, { type InnerModalProps } from "ez-modal-react";
-import { Field, Form, Formik } from "formik";
+import { Field, Form } from "formik";
import { generate } from "generate-password-browser";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { updateAuth } from "src/api/backend";
import { Button } from "src/components";
+import { FormModal } from "src/components/Form";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
@@ -35,103 +36,104 @@ const SetPasswordModal = EasyModal.create(({ id, visible, remove }: Props) => {
};
return (
-
-
- {() => (
-
- )}
-
-
+
+ {({ onHide }) => (
+
+ )}
+
);
});
diff --git a/frontend/src/modals/StreamModal.tsx b/frontend/src/modals/StreamModal.tsx
index b87a259c4..417ae3103 100644
--- a/frontend/src/modals/StreamModal.tsx
+++ b/frontend/src/modals/StreamModal.tsx
@@ -1,9 +1,10 @@
import EasyModal, { type InnerModalProps } from "ez-modal-react";
-import { Field, Form, Formik } from "formik";
+import { Field, Form } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
+import { FormModal } from "src/components/Form";
import { useSetStream, useStream } from "src/hooks";
import { intl, T } from "src/locale";
import { validateNumber, validateString } from "src/modules/Validations";
@@ -46,15 +47,22 @@ const StreamModal = EasyModal.create(({ id, visible, remove }: Props) => {
};
return (
-
- {!isLoading && error && (
-
- {error?.message || "Unknown error"}
-
+ <>
+ {(isLoading || (error && !data)) && (
+
+ {!isLoading && error && (
+
+ {error?.message || "Unknown error"}
+
+ )}
+ {isLoading && }
+
)}
- {isLoading && }
{!isLoading && data && (
- {
}
onSubmit={onSubmit}
>
- {({ setFieldValue }: any) => (
+ {({ onHide, setFieldValue }: any) => (
)}
-
+
)}
-
+ >
);
});
diff --git a/frontend/src/modals/TwoFactorModal.tsx b/frontend/src/modals/TwoFactorModal.tsx
index aa76d598c..31b2a0ef0 100644
--- a/frontend/src/modals/TwoFactorModal.tsx
+++ b/frontend/src/modals/TwoFactorModal.tsx
@@ -6,6 +6,7 @@ import Modal from "react-bootstrap/Modal";
import QRCode from "react-qr-code";
import { disable2FA, enable2FA, get2FAStatus, regenerateBackupCodes, start2FASetup } from "src/api/backend";
import { Button } from "src/components";
+import { confirmDiscardChanges, FormDirtyState } from "src/components/Form";
import { T } from "src/locale";
import { validateString } from "src/modules/Validations";
import styles from "./TwoFactorModal.module.css";
@@ -28,6 +29,13 @@ const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null);
const [backupCodes, setBackupCodes] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
+ const [formDirty, setFormDirty] = useState(false);
+
+ const handleHide = () => {
+ if (!isSubmitting && confirmDiscardChanges(formDirty)) {
+ remove();
+ }
+ };
const loadStatus = useCallback(async () => {
try {
@@ -172,8 +180,9 @@ const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
/>
- {() => (
+ {({ dirty }) => (
- {() => (
+ {({ dirty }) => (
+
{({ field, form }: any) => (