diff --git a/html/html/include/wui.js b/html/html/include/wui.js
index e65924e29..e219f5ca4 100644
--- a/html/html/include/wui.js
+++ b/html/html/include/wui.js
@@ -23,6 +23,7 @@
 
 import {WUIcore_i18n as WUI_i18n} from "./wui_core.mjs";
 
+import {WUIdialog_confirm as WUI_confirm} from "./wui_dialogs.mjs";
 import {WUImodule_rrdimage as WUI_rrdimage} from "./wui_rrdimage.mjs";
 
 //--- WUI main class ---
@@ -33,11 +34,16 @@ class WUImain {
 		this.i18n = new WUI_i18n();
 
 		//- Modules -
+		// Dialogs
+		this.dialogs = {};
+		this.dialogs.confirm = new WUI_confirm(this.i18n);
+
 		// RRDtool graph images
 		this.rrdimage = new WUI_rrdimage(this.i18n);
 
 		//- Defaults -
 		// These modules are available on every page:
+		this.dialogs.confirm.enabled = true;
 		this.rrdimage.enabled = true;
 	}
 }
diff --git a/html/html/include/wui_core.mjs b/html/html/include/wui_core.mjs
index b7b729396..ab5338f12 100644
--- a/html/html/include/wui_core.mjs
+++ b/html/html/include/wui_core.mjs
@@ -27,7 +27,7 @@ export class WUIcore_moduleBase {
 	//- Private properties -
 	#enabled;		// Activation state, disabled by default
 	#readyState; 	// Loading state similar to Document.readyState
-	#namespace;		// Namespace derived from the class name (without "WUImod_" prefix)
+	#namespace;		// Namespace derived from the class name (without "WUImodule_"/"WUIdialog_" prefix)
 
 	//- Class constructor -
 	constructor(translations) {
@@ -101,6 +101,81 @@ export class WUIcore_moduleBase {
 	}
 }
 
+//--- Modal dialog template ---
+// Make sure that overridden functions are still executed with super()!
+// Text fields and buttons are identified by their data-... attributes:
+// data-textbox="foo", data-action="bar". See setText for reference.
+// Events should only be managed by the modules, there is no public interface by design.
+export class WUIcore_dialogBase extends WUIcore_moduleBase {
+	//- Private properties -
+	#modalId;		// Element ID of the overlay div box with dialog window
+
+	//- Class constructor -
+	constructor(translations) {
+		super(translations);
+
+		//- Protected properties -
+		// jQuery object, reference to dialog div box
+		this._$modal = $();
+	}
+
+	// DOMContentLoaded/jQuery.ready event handler
+	_handleDOMReady() {
+		super._handleDOMReady();
+
+		// modalId was set before, but the document was not ready yet
+		if(this.modalId && (! this.hasModal)) {
+			this._$modal = $(`#${this.modalId}`).first();
+		}
+	}
+
+	// Element ID of dialog overlay div box
+	// This element must be hidden by default and render a modal dialog when activated.
+	set modalId(id) {
+		this.#modalId = id;
+
+		// Delay attaching element until DOM is ready
+		if(this.readyState === "complete") {
+			this._$modal = $(`#${id}`).first();
+		}
+	}
+	get modalId() {
+		return this.#modalId;
+	}
+
+	// Check if modal dialog element has been attached
+	get hasModal() {
+		return (this._$modal.length === 1);
+	}
+
+	// Show/hide modal overlay by setting CSS "display" property
+	// The top modal element should always be a simple overlay, so that display=block does not disturb the layout.
+	showModal() {
+		this._$modal.css("display", "block");
+	}
+	hideModal() {
+		this._$modal.css("display", "none");
+	}
+	get isVisible() {
+		return (this._$modal.css("display") === "block");
+	}
+
+	// Set text field content. Fields are identified by their data-textbox attribute:
+	// <span data-textbox="message"></span> -> field "message"
+	setText(field, value) {
+		this._$modal.find(`[data-textbox="${field}"]`).text(value);
+	}
+
+	//### Protected properties ###
+	
+	// Get a dialog window button as a jQuery object. Buttons are identified by their data-action attribute:
+	// <button type="button" data-action="submit">OK</button> -> action "submit"
+	// Button actions must be unique within the modal window. Events are managed internally only, do not add custom handlers.
+	_$getButton(action) {
+		return this._$modal.find(`button[data-action="${action}"]`).first();
+	}
+}
+
 //--- Simple translation strings helper ---
 export class WUIcore_i18n {
 	//- Private properties -
diff --git a/html/html/include/wui_dialogs.mjs b/html/html/include/wui_dialogs.mjs
new file mode 100644
index 000000000..a2b3bdbb4
--- /dev/null
+++ b/html/html/include/wui_dialogs.mjs
@@ -0,0 +1,97 @@
+/*#############################################################################
+#                                                                             #
+# IPFire.org - A linux based firewall                                         #
+# Copyright (C) 2007-2023  IPFire Team  <info@ipfire.org>                     #
+#                                                                             #
+# This program is free software: you can redistribute it and/or modify        #
+# it under the terms of the GNU General Public License as published by        #
+# the Free Software Foundation, either version 3 of the License, or           #
+# (at your option) any later version.                                         #
+#                                                                             #
+# This program is distributed in the hope that it will be useful,             #
+# but WITHOUT ANY WARRANTY; without even the implied warranty of              #
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
+# GNU General Public License for more details.                                #
+#                                                                             #
+# You should have received a copy of the GNU General Public License           #
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
+#                                                                             #
+#############################################################################*/
+
+// IPFire Web User Interface - JavaScript module
+
+import {WUIcore_dialogBase as WUI_dialog} from "./wui_core.mjs";
+
+//--- Form submit confirm dialog ---
+// Text fields: data-textbox "title" "subheading" "message"
+// Buttons: data-action "submit" "cancel" "close"
+export class WUIdialog_confirm extends WUI_dialog {
+
+	// DOMContentLoaded/jQuery.ready event handler
+	_handleDOMReady() {
+		super._handleDOMReady();
+
+		// Process all forms with confirmation request, attach submit events
+		$("form[data-confirm-message]").each((i, formElem) => {
+			const $form = $(formElem);
+			$form.on(`submit.${this.namespace}`, {"form": $form, "message": $form.data("confirmMessage")}, this.#handleFormSubmit.bind(this));
+		});
+	}
+
+	// Form with confirmation "submit" event handler
+	async #handleFormSubmit(event) {
+		event.preventDefault();
+
+		const $form = event.data["form"];
+		this.prepareModal(event.data["message"], $form.data("confirmTitle"), $form.data("confirmSubheading"));
+
+		// Show the dialog and wait for user interaction
+		try {
+			const response = await this.requestAsync();
+			if(response === true) {
+				// Trigger native HTML submit() method, since it does not raise another submit event that would cause a loop
+				event.currentTarget.submit();
+			}
+		} catch(error) {
+			// User closed the window, do nothing
+		}
+	}
+
+	// Show modal confirmation request dialog
+	// Returns a promise that resolves true/false upon user interaction and rejects when the window is closed.
+	requestAsync() {
+		// Attach promise to submit/cancel button "click" events
+		const whenConfirm = new Promise((resolve, reject) => {
+			this._$getButton("submit").on(`click.${this.namespace}`, () => { resolve(true); });
+			this._$getButton("cancel").on(`click.${this.namespace}`, () => { resolve(false); });
+
+			this._$getButton("close").on(`click.${this.namespace}`, () => { reject(new Error("dialog window closed")); });
+		}).finally(() => {
+			// Always hide the window and detach all button events after any click,
+			// so that the dialog can be used multiple times without side effects.
+			this.hideModal();
+			this.#clearButtonEvents();
+		});
+
+		// Show dialog, default action is "close"
+		this.showModal();
+		this._$getButton("close").focus();
+
+		return whenConfirm;
+	}
+
+	// Prepare dialog window: set texts, but leave hidden and without events
+	prepareModal(message, title = undefined, subheading = undefined) {
+		this.hideModal();
+		this.#clearButtonEvents();
+
+		this.setText("message", message);
+		this.setText("title", title ?? this._i18n("title"));
+		this.setText("subheading", subheading ?? this._i18n("subheading"));
+	}
+
+	// Remove all active events from dialog window buttons
+	#clearButtonEvents() {
+		this._$modal.find(`button[data-action]`).off(`.${this.namespace}`);
+	}
+}
diff --git a/html/html/themes/ipfire/include/css/style.css b/html/html/themes/ipfire/include/css/style.css
index 96d0519f5..f4ef1769c 100644
--- a/html/html/themes/ipfire/include/css/style.css
+++ b/html/html/themes/ipfire/include/css/style.css
@@ -377,3 +377,78 @@ div.rrdimage > img {
 	max-width: 100%;
 	min-height: 290px;
 }
+
+/* Modal dialog box */
+
+div.dialog-overlay {
+	display: none;
+	z-index: 99;
+
+	left: 0;
+	top: 0;
+	width: 100%;
+	height: 100%;
+	position: fixed;
+	overflow: auto;
+
+	background-color: rgba(0, 0, 0, 0.5);
+	animation: fadeIn 0.5s;
+}
+@keyframes fadeIn {
+	from { opacity: 0; }
+	to { opacity: 1; }
+}
+
+div.dialog-window {
+	top: 15%;
+	width: 550px;
+	margin:  auto;
+	padding: 0;
+	position: relative;
+
+	border: 1px solid black;
+	background: #fff url('../../images/n2.gif') 0px 0px repeat-x;
+	border-radius: 3px;
+	box-shadow: 3px 3px 15px rgba(0, 0, 0, 0.7);
+}
+
+.dialog-window .section {
+	box-sizing: border-box;
+	padding: 1em 1.5em;
+	text-align: left;
+}
+.dialog-window .section:not(:last-child) {
+	border-bottom: 1px solid silver;
+}
+
+.dialog-window .header > h3 {
+	display: inline-block;
+	color: #66000F;
+	font-size: 1.6em;
+}
+.dialog-window .header > button[data-action="close"] {
+	border: none;
+	background: none;
+	font-size: 1.2em;
+	font-weight: bold;
+	cursor: pointer;
+}
+
+.dialog-window .content {
+	line-height: 150%;
+}
+
+.dialog-window .header,
+.dialog-window .controls {
+	display:flex;
+	flex-direction: row;
+	align-items: center;
+	justify-content: space-between;
+}
+.dialog-window .controls > button {
+	padding: 0.3em;
+	min-width: 35%;
+}
+.dialog-window .controls > button[data-action="submit"] {
+	font-weight: bold;
+}
diff --git a/html/html/themes/ipfire/include/functions.pl b/html/html/themes/ipfire/include/functions.pl
index bc66d7fdb..784b2f398 100644
--- a/html/html/themes/ipfire/include/functions.pl
+++ b/html/html/themes/ipfire/include/functions.pl
@@ -217,6 +217,34 @@ print <<END;
 
 		<strong>$system_release</strong>
 	</div>
+
+	<!-- Modal confirm dialog -->
+	<div class="dialog-overlay" id="modal-dialog_confirm">
+		<div class="dialog-window">
+			<div class="section header">
+				<h3 data-textbox="title"></h3>
+				<button type="button" data-action="close">&times;</button>
+			</div>
+			<div class="section content">
+				<span data-textbox="subheading"></span><br>
+				<strong data-textbox="message"></strong>
+			</div>
+			<div class="section controls">
+				<button type="button" data-action="cancel">$Lang::tr{'dialog confirm cancel'}</button>
+				<button type="button" data-action="submit">$Lang::tr{'dialog confirm submit'}</button>
+			</div>
+		</div>
+	</div>
+	<script type="module">
+		import wui from "/include/wui.js";
+
+		wui.i18n.load({
+			"title": "$Lang::tr{'dialog confirm title'}",
+			"subheading": "$Lang::tr{'dialog confirm subheading'}"
+		}, "confirm");
+
+		wui.dialogs.confirm.modalId = "modal-dialog_confirm";
+	</script>
 </body>
 </html>
 END
diff --git a/langs/en/cgi-bin/en.pl b/langs/en/cgi-bin/en.pl
index 729516538..df9deda53 100644
--- a/langs/en/cgi-bin/en.pl
+++ b/langs/en/cgi-bin/en.pl
@@ -84,6 +84,10 @@
 'ConnSched up' => 'Up',
 'ConnSched weekdays' => 'Days of the week:',
 'Daily' => 'Daily',
+'dialog confirm title' => 'Permanently save changes?',
+'dialog confirm subheading' => 'The following action cannot be undone:',
+'dialog confirm submit' => 'Yes, continue',
+'dialog confirm cancel' => 'No, cancel',
 'Disabled' => 'Disabled',
 'Edit an existing route' => 'Edit an existing route',
 'Enter TOS' => 'Activate or deactivate TOS-bits <br /> and then press <i>Save</i>.',
