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:
co-authored by
Claude Opus 5
parent
749f42f481
commit
c9ffb9c698
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user