One prompt for the whole app, asked where the browser cannot refuse it

Fifteen destructive actions were still gated behind window.confirm(), the
same call that made removing a home charger look broken: a browser that
suppresses native dialogs never shows it and returns false, so deleting a
service, a part, a user or an organization would quietly not happen and say
nothing about why.

askConfirm() puts the question in the page and resolves to what was
actually pressed, so each call site changed by one line and reads the way
it did before. ConfirmDialog is mounted once in App.vue and draws over
everything, including a modal — removing a server is asked from inside one,
which is what Modal's new zIndex is for.

The home charger keeps its own inline prompt: that one belongs to its row
rather than to the middle of the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 12:02:38 +02:00
co-authored by Claude Opus 5
parent 749f42f481
commit c9ffb9c698
10 changed files with 108 additions and 18 deletions
+4
View File
@@ -7,6 +7,7 @@ import { servers } from "./servers";
import { api } from "./api"; import { api } from "./api";
import { t } from "./i18n"; import { t } from "./i18n";
import Logo from "./components/Logo.vue"; import Logo from "./components/Logo.vue";
import ConfirmDialog from "./components/ConfirmDialog.vue";
import ServerSwitcher from "./components/ServerSwitcher.vue"; import ServerSwitcher from "./components/ServerSwitcher.vue";
const router = useRouter(); const router = useRouter();
@@ -161,4 +162,7 @@ onBeforeUnmount(() => themeObserver?.disconnect());
</main> </main>
</div> </div>
</div> </div>
<!-- Asked from anywhere, drawn here, over everything. -->
<ConfirmDialog />
</template> </template>
+2 -1
View File
@@ -6,6 +6,7 @@ import { api } from "../api";
import { state } from "../auth"; import { state } from "../auth";
import { formatDate } from "../lib/format.js"; import { formatDate } from "../lib/format.js";
import { t } from "../i18n"; import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import Modal from "./Modal.vue"; import Modal from "./Modal.vue";
const users = ref([]); const users = ref([]);
@@ -121,7 +122,7 @@ async function submitResetPassword() {
} }
async function removeUser(u) { async function removeUser(u) {
if (!confirm(t("admin.confirmDelete", { name: u.name || u.email }))) return; if (!(await askConfirm(t("admin.confirmDelete", { name: u.name || u.email })))) return;
error.value = ""; error.value = "";
try { try {
await api.deleteUser(u.id); await api.deleteUser(u.id);
@@ -0,0 +1,28 @@
<script setup>
// The app's one confirmation prompt, mounted once in App.vue. It shows whatever
// askConfirm() was last asked (lib/confirm.js) and answers it.
import { t } from "../i18n";
import Modal from "./Modal.vue";
import { confirmPrompt, answerConfirm } from "../lib/confirm.js";
</script>
<template>
<!-- Above every other modal: a question can be asked from inside one (removing
a server is asked from the server dialog), and it has to sit on top of it. -->
<Modal
v-if="confirmPrompt.open"
:title="confirmPrompt.title"
:z-index="40"
@close="answerConfirm(false)"
>
<p class="text-sm text-body">{{ confirmPrompt.message }}</p>
<div class="mt-5 flex justify-end gap-2">
<button type="button" class="dh-btn dh-btn-ghost" @click="answerConfirm(false)">
{{ t("common.cancel") }}
</button>
<button type="button" class="dh-btn dh-btn-danger" @click="answerConfirm(true)">
{{ confirmPrompt.confirmLabel || t("common.yes") }}
</button>
</div>
</Modal>
</template>
+11 -2
View File
@@ -1,10 +1,19 @@
<script setup> <script setup>
defineProps({ title: { type: String, default: "" } }); // zIndex lets one modal sit above another — the confirmation prompt is asked
// from inside dialogs as well as from the page behind them.
defineProps({
title: { type: String, default: "" },
zIndex: { type: Number, default: 30 },
});
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
</script> </script>
<template> <template>
<div class="fixed inset-0 z-30 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm" @click.self="emit('close')"> <div
class="fixed inset-0 grid place-items-center bg-brand-900/40 p-4 backdrop-blur-sm"
:style="{ zIndex }"
@click.self="emit('close')"
>
<div class="dh-card w-full max-w-md p-6 shadow-pop"> <div class="dh-card w-full max-w-md p-6 shadow-pop">
<h2 v-if="title" class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ title }}</h2> <h2 v-if="title" class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ title }}</h2>
<slot /> <slot />
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { api } from "../api"; import { api } from "../api";
import { state, refreshProfile } from "../auth"; import { state, refreshProfile } from "../auth";
import { t } from "../i18n"; import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
// Organization management, adapting to who is looking: // Organization management, adapting to who is looking:
// - a user with no organization gets a "create your own" form, and becomes the // - a user with no organization gets a "create your own" form, and becomes the
@@ -97,7 +98,7 @@ async function remove(o) {
const msg = mine const msg = mine
? t("settings.org.confirmDeleteOwn", { name: o.name }) ? t("settings.org.confirmDeleteOwn", { name: o.name })
: t("settings.org.confirmDelete", { name: o.name }); : t("settings.org.confirmDelete", { name: o.name });
if (!confirm(msg)) return; if (!(await askConfirm(msg))) return;
busy.value = true; busy.value = true;
error.value = ""; error.value = "";
try { try {
+2 -1
View File
@@ -13,6 +13,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { api } from "../api"; import { api } from "../api";
import { prefs } from "../prefs"; import { prefs } from "../prefs";
import { t } from "../i18n"; import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import { formatDateTime, formatKm } from "../lib/format.js"; import { formatDateTime, formatKm } from "../lib/format.js";
const props = defineProps({ const props = defineProps({
@@ -112,7 +113,7 @@ async function connect() {
} }
async function disconnect() { async function disconnect() {
if (!confirm(t("car.provider.unlinkConfirm", { label: label.value }))) return; if (!(await askConfirm(t("car.provider.unlinkConfirm", { label: label.value })))) return;
error.value = ""; error.value = "";
try { try {
const car = await api.linkCarProvider(props.car.id, { provider: "", vehicleId: "" }); const car = await api.linkCarProvider(props.car.id, { provider: "", vehicleId: "" });
@@ -14,6 +14,7 @@ import {
} from "../servers"; } from "../servers";
import { connect, disconnect } from "../auth"; import { connect, disconnect } from "../auth";
import { t } from "../i18n"; import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import Modal from "./Modal.vue"; import Modal from "./Modal.vue";
const props = defineProps({ const props = defineProps({
@@ -70,8 +71,8 @@ function onDisconnect() {
emit("done"); emit("done");
} }
function onRemove() { async function onRemove() {
if (!confirm(t("servers.removeConfirm", { name: displayName(props.server) }))) return; if (!(await askConfirm(t("servers.removeConfirm", { name: displayName(props.server) })))) return;
removeServer(props.server.id); removeServer(props.server.id);
emit("done"); emit("done");
} }
+43
View File
@@ -0,0 +1,43 @@
// One confirmation prompt for the whole app, asked inside the page.
//
// window.confirm() reads like the obvious tool and is not: a browser that
// suppresses native dialogs — an embedded webview, a blocked-dialogs setting —
// never shows it and hands back false, so the action silently does not happen
// and nothing says why. That is indistinguishable from a broken button, and it
// was one: removing a home charger did nothing at all until the prompt moved
// into the page.
//
// askConfirm() renders the question as part of the page (ConfirmDialog.vue,
// mounted once in App.vue) and resolves to what the user actually pressed:
//
// if (!(await askConfirm(t("car.parts.confirmDelete")))) return;
import { reactive } from "vue";
export const confirmPrompt = reactive({
open: false,
title: "",
message: "",
confirmLabel: "", // blank falls back to the dialog's own wording
});
let settle = null;
export function askConfirm(message, { title = "", confirmLabel = "" } = {}) {
// Asking a second question while one is open answers the first with no.
// Nothing in the app asks two at once, and an abandoned promise would hang
// whoever awaited it.
if (settle) settle(false);
Object.assign(confirmPrompt, { open: true, title, message, confirmLabel });
return new Promise((resolve) => {
settle = resolve;
});
}
// Answers the open question. Closing the dialog any other way — the backdrop,
// Cancel — is a no, never a dropped promise.
export function answerConfirm(value) {
confirmPrompt.open = false;
const done = settle;
settle = null;
if (done) done(value);
}
+9 -8
View File
@@ -20,6 +20,7 @@ import {
} from "../lib/format.js"; } from "../lib/format.js";
import { SERVICE_PARTS, changedParts, visibleParts } from "../lib/serviceParts.js"; import { SERVICE_PARTS, changedParts, visibleParts } from "../lib/serviceParts.js";
import { t, tSplit } from "../i18n"; import { t, tSplit } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import CarFormModal from "../components/CarFormModal.vue"; import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue"; import ServiceFormModal from "../components/ServiceFormModal.vue";
import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.vue"; import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.vue";
@@ -704,7 +705,7 @@ async function onServiceSaved() {
await load(); await load();
} }
async function deleteService(id) { async function deleteService(id) {
if (!confirm(t("car.services.confirmDelete"))) return; if (!(await askConfirm(t("car.services.confirmDelete")))) return;
try { try {
await api.deleteService(id); await api.deleteService(id);
await load(); await load();
@@ -728,7 +729,7 @@ async function onTechnicalCheckSaved() {
await load(); await load();
} }
async function deleteTechnicalCheck(id) { async function deleteTechnicalCheck(id) {
if (!confirm(t("car.technical.confirmDelete"))) return; if (!(await askConfirm(t("car.technical.confirmDelete")))) return;
try { try {
await api.deleteTechnicalCheck(id); await api.deleteTechnicalCheck(id);
await load(); await load();
@@ -751,7 +752,7 @@ async function onPartSaved() {
parts.value = await api.listCarParts(props.id); parts.value = await api.listCarParts(props.id);
} }
async function deletePart(id) { async function deletePart(id) {
if (!confirm(t("car.parts.confirmDelete"))) return; if (!(await askConfirm(t("car.parts.confirmDelete")))) return;
try { try {
await api.deletePart(id); await api.deletePart(id);
parts.value = await api.listCarParts(props.id); parts.value = await api.listCarParts(props.id);
@@ -786,7 +787,7 @@ async function onFuelSaved() {
await reloadFuel(); await reloadFuel();
} }
async function deleteFuel(id) { async function deleteFuel(id) {
if (!confirm(t("car.fuel.confirmDelete"))) return; if (!(await askConfirm(t("car.fuel.confirmDelete")))) return;
try { try {
await api.deleteFuel(id); await api.deleteFuel(id);
await reloadFuel(); await reloadFuel();
@@ -822,7 +823,7 @@ async function onChargingSaved() {
await reloadCharging(); await reloadCharging();
} }
async function deleteCharging(id) { async function deleteCharging(id) {
if (!confirm(t("car.charging.confirmDelete"))) return; if (!(await askConfirm(t("car.charging.confirmDelete")))) return;
try { try {
await api.deleteCharging(id); await api.deleteCharging(id);
await reloadCharging(); await reloadCharging();
@@ -851,7 +852,7 @@ async function onMaintenanceSaved() {
]); ]);
} }
async function deleteMaintenance(id) { async function deleteMaintenance(id) {
if (!confirm(t("car.maintenance.confirmDelete"))) return; if (!(await askConfirm(t("car.maintenance.confirmDelete")))) return;
try { try {
await api.deleteMaintenance(id); await api.deleteMaintenance(id);
maintenance.value = await api.listCarMaintenance(props.id); maintenance.value = await api.listCarMaintenance(props.id);
@@ -879,7 +880,7 @@ async function onDocumentSaved() {
]); ]);
} }
async function deleteDocument(id) { async function deleteDocument(id) {
if (!confirm(t("car.documents.confirmDelete"))) return; if (!(await askConfirm(t("car.documents.confirmDelete")))) return;
try { try {
await api.deleteDocument(id); await api.deleteDocument(id);
[documents.value, reminders.value] = await Promise.all([ [documents.value, reminders.value] = await Promise.all([
@@ -948,7 +949,7 @@ async function reopenReminder(r) {
} }
} }
async function deleteReminder(id) { async function deleteReminder(id) {
if (!confirm(t("car.reminders.confirmDelete"))) return; if (!(await askConfirm(t("car.reminders.confirmDelete")))) return;
try { try {
await api.deleteReminder(id); await api.deleteReminder(id);
reminders.value = await api.listCarReminders(props.id); reminders.value = await api.listCarReminders(props.id);
+4 -3
View File
@@ -6,6 +6,7 @@ import { state, isAdmin, logout, refreshProfile } from "../auth";
import { prefs, applyProfilePrefs } from "../prefs"; import { prefs, applyProfilePrefs } from "../prefs";
import { formatDate, formatMoney } from "../lib/format.js"; import { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n"; import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import OrgManager from "../components/OrgManager.vue"; import OrgManager from "../components/OrgManager.vue";
import AdminUsers from "../components/AdminUsers.vue"; import AdminUsers from "../components/AdminUsers.vue";
@@ -546,7 +547,7 @@ async function generateAnkerToken() {
async function revokeAnkerToken() { async function revokeAnkerToken() {
const sn = ankerCtlSerial.value.trim(); const sn = ankerCtlSerial.value.trim();
if (!sn) return; if (!sn) return;
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return; if (!(await askConfirm(t("settings.integrations.controlRevokeConfirm")))) return;
ankerCtlError.value = ""; ankerCtlError.value = "";
ankerNewToken.value = ""; ankerNewToken.value = "";
try { try {
@@ -873,7 +874,7 @@ async function onImportFileChosen(e) {
importError.value = t("settings.advanced.notExport"); importError.value = t("settings.advanced.notExport");
return; return;
} }
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) { if (!(await askConfirm(t("settings.advanced.confirmImport", { count: payload.cars.length })))) {
return; return;
} }
@@ -934,7 +935,7 @@ async function cancelDeletion() {
} }
async function finalizeDeletion() { async function finalizeDeletion() {
if (!confirm(t("settings.danger.confirmFinalize"))) return; if (!(await askConfirm(t("settings.danger.confirmFinalize")))) return;
deleteError.value = ""; deleteError.value = "";
try { try {
await api.finalizeAccountDeletion(); await api.finalizeAccountDeletion();