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 { t } from "./i18n";
import Logo from "./components/Logo.vue";
import ConfirmDialog from "./components/ConfirmDialog.vue";
import ServerSwitcher from "./components/ServerSwitcher.vue";
const router = useRouter();
@@ -161,4 +162,7 @@ onBeforeUnmount(() => themeObserver?.disconnect());
</main>
</div>
</div>
<!-- Asked from anywhere, drawn here, over everything. -->
<ConfirmDialog />
</template>
+2 -1
View File
@@ -6,6 +6,7 @@ import { api } from "../api";
import { state } from "../auth";
import { formatDate } from "../lib/format.js";
import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import Modal from "./Modal.vue";
const users = ref([]);
@@ -121,7 +122,7 @@ async function submitResetPassword() {
}
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 = "";
try {
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>
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"]);
</script>
<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">
<h2 v-if="title" class="mb-4 text-lg font-bold tracking-[-0.02em] text-strong">{{ title }}</h2>
<slot />
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { api } from "../api";
import { state, refreshProfile } from "../auth";
import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
// Organization management, adapting to who is looking:
// - 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
? t("settings.org.confirmDeleteOwn", { name: o.name })
: t("settings.org.confirmDelete", { name: o.name });
if (!confirm(msg)) return;
if (!(await askConfirm(msg))) return;
busy.value = true;
error.value = "";
try {
+2 -1
View File
@@ -13,6 +13,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { api } from "../api";
import { prefs } from "../prefs";
import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import { formatDateTime, formatKm } from "../lib/format.js";
const props = defineProps({
@@ -112,7 +113,7 @@ async function connect() {
}
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 = "";
try {
const car = await api.linkCarProvider(props.car.id, { provider: "", vehicleId: "" });
@@ -14,6 +14,7 @@ import {
} from "../servers";
import { connect, disconnect } from "../auth";
import { t } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import Modal from "./Modal.vue";
const props = defineProps({
@@ -70,8 +71,8 @@ function onDisconnect() {
emit("done");
}
function onRemove() {
if (!confirm(t("servers.removeConfirm", { name: displayName(props.server) }))) return;
async function onRemove() {
if (!(await askConfirm(t("servers.removeConfirm", { name: displayName(props.server) })))) return;
removeServer(props.server.id);
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";
import { SERVICE_PARTS, changedParts, visibleParts } from "../lib/serviceParts.js";
import { t, tSplit } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import CarFormModal from "../components/CarFormModal.vue";
import ServiceFormModal from "../components/ServiceFormModal.vue";
import TechnicalCheckFormModal from "../components/TechnicalCheckFormModal.vue";
@@ -704,7 +705,7 @@ async function onServiceSaved() {
await load();
}
async function deleteService(id) {
if (!confirm(t("car.services.confirmDelete"))) return;
if (!(await askConfirm(t("car.services.confirmDelete")))) return;
try {
await api.deleteService(id);
await load();
@@ -728,7 +729,7 @@ async function onTechnicalCheckSaved() {
await load();
}
async function deleteTechnicalCheck(id) {
if (!confirm(t("car.technical.confirmDelete"))) return;
if (!(await askConfirm(t("car.technical.confirmDelete")))) return;
try {
await api.deleteTechnicalCheck(id);
await load();
@@ -751,7 +752,7 @@ async function onPartSaved() {
parts.value = await api.listCarParts(props.id);
}
async function deletePart(id) {
if (!confirm(t("car.parts.confirmDelete"))) return;
if (!(await askConfirm(t("car.parts.confirmDelete")))) return;
try {
await api.deletePart(id);
parts.value = await api.listCarParts(props.id);
@@ -786,7 +787,7 @@ async function onFuelSaved() {
await reloadFuel();
}
async function deleteFuel(id) {
if (!confirm(t("car.fuel.confirmDelete"))) return;
if (!(await askConfirm(t("car.fuel.confirmDelete")))) return;
try {
await api.deleteFuel(id);
await reloadFuel();
@@ -822,7 +823,7 @@ async function onChargingSaved() {
await reloadCharging();
}
async function deleteCharging(id) {
if (!confirm(t("car.charging.confirmDelete"))) return;
if (!(await askConfirm(t("car.charging.confirmDelete")))) return;
try {
await api.deleteCharging(id);
await reloadCharging();
@@ -851,7 +852,7 @@ async function onMaintenanceSaved() {
]);
}
async function deleteMaintenance(id) {
if (!confirm(t("car.maintenance.confirmDelete"))) return;
if (!(await askConfirm(t("car.maintenance.confirmDelete")))) return;
try {
await api.deleteMaintenance(id);
maintenance.value = await api.listCarMaintenance(props.id);
@@ -879,7 +880,7 @@ async function onDocumentSaved() {
]);
}
async function deleteDocument(id) {
if (!confirm(t("car.documents.confirmDelete"))) return;
if (!(await askConfirm(t("car.documents.confirmDelete")))) return;
try {
await api.deleteDocument(id);
[documents.value, reminders.value] = await Promise.all([
@@ -948,7 +949,7 @@ async function reopenReminder(r) {
}
}
async function deleteReminder(id) {
if (!confirm(t("car.reminders.confirmDelete"))) return;
if (!(await askConfirm(t("car.reminders.confirmDelete")))) return;
try {
await api.deleteReminder(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 { formatDate, formatMoney } from "../lib/format.js";
import { t, tSplit, TRANSLATED_LANGUAGES } from "../i18n";
import { askConfirm } from "../lib/confirm.js";
import OrgManager from "../components/OrgManager.vue";
import AdminUsers from "../components/AdminUsers.vue";
@@ -546,7 +547,7 @@ async function generateAnkerToken() {
async function revokeAnkerToken() {
const sn = ankerCtlSerial.value.trim();
if (!sn) return;
if (!confirm(t("settings.integrations.controlRevokeConfirm"))) return;
if (!(await askConfirm(t("settings.integrations.controlRevokeConfirm")))) return;
ankerCtlError.value = "";
ankerNewToken.value = "";
try {
@@ -873,7 +874,7 @@ async function onImportFileChosen(e) {
importError.value = t("settings.advanced.notExport");
return;
}
if (!confirm(t("settings.advanced.confirmImport", { count: payload.cars.length }))) {
if (!(await askConfirm(t("settings.advanced.confirmImport", { count: payload.cars.length })))) {
return;
}
@@ -934,7 +935,7 @@ async function cancelDeletion() {
}
async function finalizeDeletion() {
if (!confirm(t("settings.danger.confirmFinalize"))) return;
if (!(await askConfirm(t("settings.danger.confirmFinalize")))) return;
deleteError.value = "";
try {
await api.finalizeAccountDeletion();