From ad1fb5338fc688a3b858db998c7d8c12d49a433d Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 2 Oct 2026 23:07:43 +0200 Subject: [PATCH] Replace the browser's confirm() and prompt() pop-ups with in-app dialogs All 31 native dialogs (27 confirms, 4 text prompts: ignore reason, two "exclude a range" boxes, tag rename) now use the app's own Tabler-styled modals, which follow the light/dark theme. A small promise-based API (utils/dialogs.ts: confirmDialog, promptDialog) and a single DialogHost mounted once in App mean call sites just await it in place of the browser call - no hooks or per-page modal state. Every call site was already in an async function, so each is a one-line swap. Each dialog now has a title and a main button that names the action ("Delete", "Stop now", "Run now") instead of "OK", with destructive ones in red. Escape cancels, Enter confirms, Tab stays inside the dialog, the page behind stops scrolling, and focus returns to the button that was clicked. Destructive dialogs start with focus on Cancel so a stray Enter can't delete anything. Text prompts pre-select their default and disable the main button until something is typed where it's required, and still tell cancelling (null) apart from confirming an empty box (""). Clicking the backdrop cancels, but releasing a text selection over it doesn't. Dialogs asked for together appear one after another. Verified in a browser on a test page using the real component and the app's real stylesheet: Escape, Enter, Tab trapping, focus handling, required and optional prompts, backdrop clicks, queuing, scroll lock and dark mode. The 31 call sites themselves were checked by search and typecheck rather than clicked through in the logged-in app. Co-Authored-By: Claude Sonnet 5.5 --- web/src/App.tsx | 2 + web/src/components/DialogHost.tsx | 146 ++++++++++++++++++++++ web/src/components/ServerPorts.tsx | 4 +- web/src/pages/AdminLinks.tsx | 3 +- web/src/pages/Consistency.tsx | 22 ++-- web/src/pages/DiagLog.tsx | 3 +- web/src/pages/Dns.tsx | 5 +- web/src/pages/Docker.tsx | 3 +- web/src/pages/Domains.tsx | 3 +- web/src/pages/Integrations.tsx | 3 +- web/src/pages/Ipam.tsx | 17 ++- web/src/pages/Ports.tsx | 3 +- web/src/pages/Proxmox.tsx | 12 +- web/src/pages/Secrets.tsx | 5 +- web/src/pages/Semaphore.tsx | 3 +- web/src/pages/ServerDetail.tsx | 15 ++- web/src/pages/Servers.tsx | 3 +- web/src/pages/Sessions.tsx | 3 +- web/src/pages/Tailscale.tsx | 5 +- web/src/pages/settings/BackupSettings.tsx | 3 +- web/src/pages/settings/CacheSettings.tsx | 3 +- web/src/pages/settings/LogSettings.tsx | 3 +- web/src/pages/settings/TagSettings.tsx | 15 ++- web/src/utils/dialogs.ts | 69 ++++++++++ 24 files changed, 314 insertions(+), 39 deletions(-) create mode 100644 web/src/components/DialogHost.tsx create mode 100644 web/src/utils/dialogs.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 5152b24..341c744 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -39,6 +39,7 @@ import LogSettings from "./pages/settings/LogSettings"; import BackupSettings from "./pages/settings/BackupSettings"; import AppShell from "./layout/AppShell"; import { setDateTimeSettings } from "./utils/date"; +import DialogHost from "./components/DialogHost"; import { setPageSize } from "./utils/pageSize"; const roleRank: Record = { viewer: 0, operator: 1, admin: 2 }; @@ -165,4 +166,5 @@ export default function App() { ); + } diff --git a/web/src/components/DialogHost.tsx b/web/src/components/DialogHost.tsx new file mode 100644 index 0000000..7733a20 --- /dev/null +++ b/web/src/components/DialogHost.tsx @@ -0,0 +1,146 @@ +import { useEffect, useId, useRef, useState, type FormEvent, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import { settle, subscribe, type DialogRequest } from "../utils/dialogs"; + +const FOCUSABLE = "button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [href]"; + +/** Draws the confirm/prompt dialogs asked for through utils/dialogs. Mount once, near the root. */ +export default function DialogHost() { + const [current, setCurrent] = useState(null); + useEffect(() => subscribe(setCurrent), []); + if (!current) return null; + // Keyed, so back-to-back dialogs each start fresh (an empty text box, the right button focused). + return createPortal(, document.body); +} + +function DialogView({ request }: { request: DialogRequest }) { + const titleId = useId(); + const inputId = useId(); + const modalRef = useRef(null); + const inputRef = useRef(null); + const cancelRef = useRef(null); + const confirmRef = useRef(null); + const isPrompt = request.kind === "prompt"; + const danger = request.kind === "confirm" && !!request.danger; + const [value, setValue] = useState(request.kind === "prompt" ? (request.defaultValue ?? "") : ""); + + const cancel = () => settle(request, false); + const blocked = request.kind === "prompt" && !!request.required && !value.trim(); + + // On open: stop the page behind from scrolling (without the scrollbar vanishing and shifting the layout), put the + // keyboard focus where it's useful, and on close hand it back to whatever had it — the button that was clicked. + useEffect(() => { + const previouslyFocused = document.activeElement as HTMLElement | null; + const { overflow, paddingRight } = document.body.style; + const scrollbar = window.innerWidth - document.documentElement.clientWidth; + document.body.style.overflow = "hidden"; + if (scrollbar > 0) document.body.style.paddingRight = `${scrollbar}px`; + + if (isPrompt) { + inputRef.current?.focus(); + inputRef.current?.select(); + } else if (danger) { + cancelRef.current?.focus(); // the safe answer, so a stray Enter doesn't delete anything + } else { + confirmRef.current?.focus(); + } + + return () => { + document.body.style.overflow = overflow; + document.body.style.paddingRight = paddingRight; + previouslyFocused?.focus?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.stopPropagation(); + cancel(); + return; + } + if (e.key !== "Tab") return; + // Keep Tab inside the dialog. + const items = Array.from(modalRef.current?.querySelectorAll(FOCUSABLE) ?? []); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (blocked) return; + settle(request, true, value); + } + + const title = request.title ?? (isPrompt ? "Enter a value" : "Please confirm"); + + return ( + <> +
{ + if (e.target === e.currentTarget) cancel(); + }} + > +
+
+ {danger &&
} +
+
+ {title} +
+
+
+
{request.message}
+ {request.kind === "prompt" && ( +
+ {request.label && ( + + )} + setValue(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ )} +
+
+ + +
+ +
+
+
+ + ); +} diff --git a/web/src/components/ServerPorts.tsx b/web/src/components/ServerPorts.tsx index ca6f964..c669ca7 100644 --- a/web/src/components/ServerPorts.tsx +++ b/web/src/components/ServerPorts.tsx @@ -7,6 +7,7 @@ import { downloadCsv } from "../utils/csv"; import { readableError } from "../utils/errors"; import Pagination from "./Pagination"; import SortableTh from "./SortableTh"; +import { confirmDialog } from "../utils/dialogs"; const PRESETS: { key: string; label: string; from: number; to: number }[] = [ { key: "well-known", label: "1–1024 (well-known)", from: 1, to: 1024 }, @@ -188,7 +189,8 @@ export default function ServerPorts({ async function removeNote(entry: PortEntry) { if (entry.id === null) return; const what = entry.state === "reserved" ? `Remove the reservation for ${entry.protocol}/${entry.port}?` : `Clear the note on ${entry.protocol}/${entry.port}?`; - if (!confirm(what)) return; + const reserved = entry.state === "reserved"; + if (!(await confirmDialog({ title: reserved ? "Remove reservation" : "Clear note", message: what, confirmLabel: reserved ? "Remove" : "Clear", danger: true }))) return; try { await api.servers.ports.remove(serverId, entry.id); setData(await api.servers.ports.list(serverId)); diff --git a/web/src/pages/AdminLinks.tsx b/web/src/pages/AdminLinks.tsx index 57df684..200321f 100644 --- a/web/src/pages/AdminLinks.tsx +++ b/web/src/pages/AdminLinks.tsx @@ -5,6 +5,7 @@ import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl"; +import { confirmDialog } from "../utils/dialogs"; interface LinkForm { serverId: number | ""; @@ -133,7 +134,7 @@ export default function AdminLinks({ user }: { user: CurrentUser }) { } async function remove(link: AdminLink) { - if (!confirm(`Remove the "${link.label}" link from ${link.serverName}?`)) return; + if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link from ${link.serverName}?`, confirmLabel: "Remove", danger: true }))) return; setError(null); try { await api.servers.removeLink(link.serverId, link.id); diff --git a/web/src/pages/Consistency.tsx b/web/src/pages/Consistency.tsx index e68b53b..5ff61d1 100644 --- a/web/src/pages/Consistency.tsx +++ b/web/src/pages/Consistency.tsx @@ -5,6 +5,7 @@ import { downloadCsv } from "../utils/csv"; import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { formatAgo } from "../utils/duration"; import { readableError } from "../utils/errors"; +import { promptDialog } from "../utils/dialogs"; const KINDS: { kind: ConsistencyKind; title: string; blurb: string }[] = [ { kind: "ip_conflict", title: "Address conflicts", blurb: "The same address reported by more than one server." }, @@ -72,7 +73,12 @@ export default function Consistency({ user }: { user: CurrentUser }) { } async function ignore(f: ConsistencyFinding) { - const reason = window.prompt("Why is this fine? (optional — shown in the Ignored list)", ""); + const reason = await promptDialog({ + title: "Ignore this finding", + message: "Why is this fine? (optional — shown in the Ignored list)", + placeholder: "e.g. intentional, or a test machine", + confirmLabel: "Ignore", + }); if (reason === null) return; // cancelled setBusyKey(f.key); setError(null); @@ -113,12 +119,14 @@ export default function Consistency({ user }: { user: CurrentUser }) { async function excludeAround(f: ConsistencyFinding) { if (!report || !f.ip) return; const suggestion = f.ip.includes(":") ? `${f.ip}/64` : `${f.ip.split(".").slice(0, 3).join(".")}.0/24`; - const range = window.prompt( - `Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike. - -Range (a network like 192.168.16.0/20, or a single address):`, - suggestion, - ); + const range = await promptDialog({ + title: "Exclude a range", + message: "Leave this range out of the report entirely — every address in it, from servers, IPAM and DNS alike.", + label: "Range (a network like 192.168.16.0/20, or a single address)", + defaultValue: suggestion, + confirmLabel: "Exclude", + required: true, + }); if (range === null || !range.trim()) return; await saveRanges([...report.excludedRanges, range.trim()]); } diff --git a/web/src/pages/DiagLog.tsx b/web/src/pages/DiagLog.tsx index 4089ad5..22322eb 100644 --- a/web/src/pages/DiagLog.tsx +++ b/web/src/pages/DiagLog.tsx @@ -4,6 +4,7 @@ import { formatDateTime, parseDbTimestamp } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; const SOURCE_LABELS: Record = { cloudflare: "Cloudflare", @@ -62,7 +63,7 @@ export default function DiagLog() { } async function handleClear() { - if (!confirm("Clear the entire diagnostic log? This cannot be undone.")) return; + if (!(await confirmDialog({ title: "Clear diagnostic log", message: "Clear the entire diagnostic log? This cannot be undone.", confirmLabel: "Clear log", danger: true }))) return; setClearing(true); try { await api.diagLog.clear(); diff --git a/web/src/pages/Dns.tsx b/web/src/pages/Dns.tsx index 226e834..472d0da 100644 --- a/web/src/pages/Dns.tsx +++ b/web/src/pages/Dns.tsx @@ -16,6 +16,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; const PROVIDER_LABELS: Record = { cloudflare: "Cloudflare", @@ -182,7 +183,7 @@ export default function Dns({ user }: { user: CurrentUser }) { async function removeRecord(r: DnsRecord) { if (!selectedProviderId || !selectedZone) return; - if (!confirm(`Delete ${r.type} record "${r.name}"?`)) return; + if (!(await confirmDialog({ title: "Delete DNS record", message: `Delete ${r.type} record "${r.name}"?`, confirmLabel: "Delete", danger: true }))) return; setError(null); try { await api.dns.records.remove(selectedProviderId, selectedZone.id, r.id); @@ -202,7 +203,7 @@ export default function Dns({ user }: { user: CurrentUser }) { } async function removeProvider(p: DnsProviderSummary) { - if (!confirm(`Delete provider "${p.name}"? This removes its cached zones and records too.`)) return; + if (!(await confirmDialog({ title: "Delete DNS provider", message: `Delete provider "${p.name}"? This removes its cached zones and records too.`, confirmLabel: "Delete", danger: true }))) return; try { await api.dns.providers.remove(p.id); if (selectedProviderId === p.id) setSelectedProviderId(null); diff --git a/web/src/pages/Docker.tsx b/web/src/pages/Docker.tsx index ede1e95..074130d 100644 --- a/web/src/pages/Docker.tsx +++ b/web/src/pages/Docker.tsx @@ -12,6 +12,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; function updateBadge(c: DockhandContainer) { if (c.updateAvailable === null) return —; @@ -124,7 +125,7 @@ export default function Docker({ user }: { user: CurrentUser }) { async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") { if (!selectedId) return; - if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return; + if (action === "stop" && !(await confirmDialog({ title: "Stop container", message: `Stop container "${c.name}"?`, confirmLabel: "Stop", danger: true }))) return; setActingOnContainer(c.id); setError(null); try { diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx index 94e6cb6..fa01744 100644 --- a/web/src/pages/Domains.tsx +++ b/web/src/pages/Domains.tsx @@ -8,6 +8,7 @@ import { useSortable } from "../hooks/useSortable"; import { usePagination } from "../hooks/usePagination"; import SortableTh from "../components/SortableTh"; import Pagination from "../components/Pagination"; +import { confirmDialog } from "../utils/dialogs"; function statusBadge(d: DomainRecord) { switch (d.status) { @@ -87,7 +88,7 @@ export default function Domains({ user }: { user: CurrentUser }) { } async function remove(d: DomainRecord) { - if (!confirm(`Stop tracking ${d.name}?`)) return; + if (!(await confirmDialog({ title: "Stop tracking domain", message: `Stop tracking ${d.name}?`, confirmLabel: "Stop tracking", danger: true }))) return; setError(null); try { await api.domains.remove(d.id); diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 9b2f0fd..eb72eb1 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -6,6 +6,7 @@ import IntegrationEditForm from "../components/IntegrationEditForm"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; const TYPE_LABELS: Record = { tailscale: "Tailscale", @@ -79,7 +80,7 @@ export default function Integrations({ user }: { user: CurrentUser }) { } async function removeIntegration(i: IntegrationSummary) { - if (!confirm(`Delete integration "${i.name}"?`)) return; + if (!(await confirmDialog({ title: "Delete integration", message: `Delete integration "${i.name}"?`, confirmLabel: "Delete", danger: true }))) return; try { await api.integrations.remove(i.id); loadIntegrations(); diff --git a/web/src/pages/Ipam.tsx b/web/src/pages/Ipam.tsx index 3a3c1dd..ff91a94 100644 --- a/web/src/pages/Ipam.tsx +++ b/web/src/pages/Ipam.tsx @@ -7,6 +7,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { useSelection } from "../hooks/useSelection"; +import { confirmDialog, promptDialog } from "../utils/dialogs"; const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" }; @@ -74,10 +75,14 @@ export default function Ipam({ user }: { user: CurrentUser }) { async function excludeAround(entry: IpamEntry) { const suggestion = isIpv6(entry.ipAddress) ? `${entry.ipAddress}/64` : `${entry.ipAddress.split(".").slice(0, 3).join(".")}.0/24`; - const range = window.prompt( - `Hide this range from IP Addresses and the Consistency report — every address in it, not just this one:`, - suggestion, - ); + const range = await promptDialog({ + title: "Exclude a range", + message: "Hide this range from IP Addresses and the Consistency report — every address in it, not just this one.", + label: "Range (a network like 172.17.0.0/16, or a single address)", + defaultValue: suggestion, + confirmLabel: "Exclude", + required: true, + }); if (range === null || !range.trim()) return; await saveRanges([...excludedRanges, range.trim()]); } @@ -139,7 +144,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { } async function remove(entry: IpamEntry) { - if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return; + if (!(await confirmDialog({ title: "Delete IP address", message: `Delete IP address "${entry.ipAddress}"?`, confirmLabel: "Delete", danger: true }))) return; setError(null); try { await api.ipam.remove(entry.id); @@ -152,7 +157,7 @@ export default function Ipam({ user }: { user: CurrentUser }) { async function bulkDelete() { const ids = Array.from(selection.selected); if (ids.length === 0) return; - if (!confirm(`Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`)) return; + if (!(await confirmDialog({ title: "Delete IP addresses", message: `Delete ${ids.length} IP address${ids.length !== 1 ? "es" : ""}?`, confirmLabel: "Delete", danger: true }))) return; setError(null); setBulkDeleting(true); try { diff --git a/web/src/pages/Ports.tsx b/web/src/pages/Ports.tsx index 4774f33..673af14 100644 --- a/web/src/pages/Ports.tsx +++ b/web/src/pages/Ports.tsx @@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; import { formatAgo } from "../utils/duration"; +import { confirmDialog } from "../utils/dialogs"; const emptyForm: PortForwardInput = { label: "", @@ -163,7 +164,7 @@ export default function Ports({ user }: { user: CurrentUser }) { } async function remove(fwd: PortForward) { - if (!confirm(`Delete port opening "${fwd.label}"?`)) return; + if (!(await confirmDialog({ title: "Delete port opening", message: `Delete port opening "${fwd.label}"?`, confirmLabel: "Delete", danger: true }))) return; setForwardError(null); try { await api.ports.forwards.remove(fwd.id); diff --git a/web/src/pages/Proxmox.tsx b/web/src/pages/Proxmox.tsx index eb35536..36ce390 100644 --- a/web/src/pages/Proxmox.tsx +++ b/web/src/pages/Proxmox.tsx @@ -15,6 +15,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; function proxmoxStatusBadge(status: string) { return status === "running" ? ( @@ -164,7 +165,16 @@ export default function Proxmox({ user }: { user: CurrentUser }) { async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") { if (!selectedId) return; - if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`)) + const kind = g.type === "qemu" ? "VM" : "container"; + if ( + action === "stop" && + !(await confirmDialog({ + title: `Stop ${kind}`, + message: `Stop ${kind} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`, + confirmLabel: "Stop now", + danger: true, + })) + ) return; setActingOnGuest(g.vmid); setError(null); diff --git a/web/src/pages/Secrets.tsx b/web/src/pages/Secrets.tsx index 1387c1c..e3eeabe 100644 --- a/web/src/pages/Secrets.tsx +++ b/web/src/pages/Secrets.tsx @@ -9,6 +9,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { useSelection } from "../hooks/useSelection"; +import { confirmDialog } from "../utils/dialogs"; const TYPE_LABELS: Record = { api_token: "API Token", @@ -134,7 +135,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { } async function remove(s: SecretRecord) { - if (!confirm(`Delete secret "${s.name}"?`)) return; + if (!(await confirmDialog({ title: "Delete secret", message: `Delete secret "${s.name}"?`, confirmLabel: "Delete", danger: true }))) return; setError(null); try { await api.secrets.remove(s.id); @@ -147,7 +148,7 @@ export default function Secrets({ user }: { user: CurrentUser }) { async function bulkDelete() { const ids = Array.from(selection.selected); if (ids.length === 0) return; - if (!confirm(`Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`)) return; + if (!(await confirmDialog({ title: "Delete secrets", message: `Delete ${ids.length} secret${ids.length !== 1 ? "s" : ""}?`, confirmLabel: "Delete", danger: true }))) return; setError(null); setBulkDeleting(true); try { diff --git a/web/src/pages/Semaphore.tsx b/web/src/pages/Semaphore.tsx index 7a19ec5..848644a 100644 --- a/web/src/pages/Semaphore.tsx +++ b/web/src/pages/Semaphore.tsx @@ -13,6 +13,7 @@ import SortableTh from "../components/SortableTh"; import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; function semaphoreStatusBadge(template: SemaphoreTemplate) { const status = template.lastTask?.status; @@ -85,7 +86,7 @@ export default function Semaphore({ user }: { user: CurrentUser }) { async function runTemplate(t: SemaphoreTemplate) { if (!selectedId) return; - if (!confirm(`Run "${t.name}" now?`)) return; + if (!(await confirmDialog({ title: "Run template", message: `Run "${t.name}" now?`, confirmLabel: "Run now" }))) return; setRunningTemplate(t.id); setError(null); try { diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index cd80079..3f84532 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -17,6 +17,7 @@ import ServerTags from "../components/ServerTags"; import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable"; import { formatDateTime } from "../utils/date"; import { guessAdminUrl, portOptionLabel } from "../utils/adminLinkUrl"; +import { confirmDialog } from "../utils/dialogs"; const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map( ([value, label]) => ({ value: value as ScheduleType, label }), @@ -258,7 +259,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { } async function deleteTask(task: TaskRecord) { - if (!confirm(`Delete "${task.name}"?`)) return; + if (!(await confirmDialog({ title: "Delete task", message: `Delete "${task.name}"?`, confirmLabel: "Delete", danger: true }))) return; try { await api.tasks.remove(task.id); loadTasks(); @@ -330,7 +331,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { } async function removeAdminLink(link: ServerLink) { - if (!confirm(`Remove the "${link.label}" link?`)) return; + if (!(await confirmDialog({ title: "Remove admin link", message: `Remove the "${link.label}" link?`, confirmLabel: "Remove", danger: true }))) return; try { await api.servers.removeLink(serverId, link.id); await loadDetail(); @@ -347,7 +348,15 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { async function guestAction(action: "start" | "stop" | "restart" | "shutdown") { if (!server.proxmoxIntegrationId || !server.proxmoxNode || !server.proxmoxGuestType || server.proxmoxVmid === null) return; - if (action === "stop" && !confirm(`Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`)) + if ( + action === "stop" && + !(await confirmDialog({ + title: "Stop server", + message: `Stop ${server.name} immediately? This is a hard power-off, not a graceful shutdown — use Shutdown instead if the guest OS should get a chance to close down cleanly.`, + confirmLabel: "Stop now", + danger: true, + })) + ) return; setActingOnGuest(true); setGuestActionError(null); diff --git a/web/src/pages/Servers.tsx b/web/src/pages/Servers.tsx index 3f49691..8da1e8d 100644 --- a/web/src/pages/Servers.tsx +++ b/web/src/pages/Servers.tsx @@ -11,6 +11,7 @@ import { downloadCsv } from "../utils/csv"; import { TagBadges } from "../components/ServerTags"; import { AGENT_RUN_HINT, agentOsOf, installCommand, uninstallCommand, type AgentOs } from "../utils/agentCommands"; import { tagBadge, useTagColors } from "../utils/tags"; +import { confirmDialog } from "../utils/dialogs"; function typeBadgeStyle(colors: Record, type: string): CSSProperties { const color = colors[type]; @@ -85,7 +86,7 @@ export default function Servers({ user }: { user: CurrentUser }) { } async function deleteServer(id: number) { - if (!confirm("Remove this server and all its tracked tasks?")) return; + if (!(await confirmDialog({ title: "Remove server", message: "Remove this server and all its tracked tasks?", confirmLabel: "Remove", danger: true }))) return; try { await api.servers.remove(id); await loadServers(); diff --git a/web/src/pages/Sessions.tsx b/web/src/pages/Sessions.tsx index e138e66..673530f 100644 --- a/web/src/pages/Sessions.tsx +++ b/web/src/pages/Sessions.tsx @@ -4,6 +4,7 @@ import { formatDateTime } from "../utils/date"; import { useSortable } from "../hooks/useSortable"; import SortableTh from "../components/SortableTh"; import { downloadCsv } from "../utils/csv"; +import { confirmDialog } from "../utils/dialogs"; function friendlyUserAgent(ua: string | null): string { if (!ua) return "—"; @@ -54,7 +55,7 @@ export default function Sessions() { const confirmMsg = isSelf ? `This is your current session — revoking it will sign you out immediately. Continue?` : `Revoke ${label}'s session? They'll be signed out immediately.`; - if (!confirm(confirmMsg)) return; + if (!(await confirmDialog({ title: isSelf ? "Sign out of this session" : "End session", message: confirmMsg, confirmLabel: isSelf ? "Sign me out" : "Revoke session", danger: true }))) return; setError(null); setRevokingId(s.id); try { diff --git a/web/src/pages/Tailscale.tsx b/web/src/pages/Tailscale.tsx index b7345d5..3c73c8d 100644 --- a/web/src/pages/Tailscale.tsx +++ b/web/src/pages/Tailscale.tsx @@ -14,6 +14,7 @@ import { usePagination } from "../hooks/usePagination"; import Pagination from "../components/Pagination"; import { downloadCsv } from "../utils/csv"; import { useSelection } from "../hooks/useSelection"; +import { confirmDialog } from "../utils/dialogs"; const KEY_EXPIRY_WARN_DAYS = 30; @@ -89,7 +90,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { async function removeDevice(device: TailscaleDevice) { if (!selectedId) return; - if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return; + if (!(await confirmDialog({ title: "Remove device", message: `Remove device "${device.label || device.hostname}" from the tailnet?`, confirmLabel: "Remove", danger: true }))) return; try { await api.integrations.tailscale.remove(selectedId, device.id); loadDevices(selectedId); @@ -119,7 +120,7 @@ export default function Tailscale({ user }: { user: CurrentUser }) { if (!selectedId) return; const ids = Array.from(selection.selected); if (ids.length === 0) return; - if (!confirm(`Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`)) return; + if (!(await confirmDialog({ title: "Remove devices", message: `Remove ${ids.length} device${ids.length !== 1 ? "s" : ""} from the tailnet?`, confirmLabel: "Remove", danger: true }))) return; setError(null); setBulkActing(true); try { diff --git a/web/src/pages/settings/BackupSettings.tsx b/web/src/pages/settings/BackupSettings.tsx index 514af86..aa27093 100644 --- a/web/src/pages/settings/BackupSettings.tsx +++ b/web/src/pages/settings/BackupSettings.tsx @@ -1,5 +1,6 @@ import { useRef, useState } from "react"; import { api, type EncryptedExportFile, type ImportResult } from "../../api/client"; +import { confirmDialog } from "../../utils/dialogs"; function downloadJson(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); @@ -65,7 +66,7 @@ export default function BackupSettings() { async function handleImport() { if (!importFile) return; - if (!confirm("Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.")) return; + if (!(await confirmDialog({ title: "Import backup", message: "Import this backup? Existing integrations and DNS providers with the same name are left untouched — only new ones are added.", confirmLabel: "Import" }))) return; setImporting(true); setImportError(null); setImportResult(null); diff --git a/web/src/pages/settings/CacheSettings.tsx b/web/src/pages/settings/CacheSettings.tsx index 46968a2..0d674ea 100644 --- a/web/src/pages/settings/CacheSettings.tsx +++ b/web/src/pages/settings/CacheSettings.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { api } from "../../api/client"; +import { confirmDialog } from "../../utils/dialogs"; export default function CacheSettings() { const [clearing, setClearing] = useState(false); @@ -7,7 +8,7 @@ export default function CacheSettings() { const [error, setError] = useState(null); async function handleClear() { - if (!confirm("Clear the DNS record cache? All zones will need to be re-synced afterwards.")) return; + if (!(await confirmDialog({ title: "Clear DNS cache", message: "Clear the DNS record cache? All zones will need to be re-synced afterwards.", confirmLabel: "Clear cache" }))) return; setClearing(true); setError(null); setCleared(false); diff --git a/web/src/pages/settings/LogSettings.tsx b/web/src/pages/settings/LogSettings.tsx index ce5f50b..9b35536 100644 --- a/web/src/pages/settings/LogSettings.tsx +++ b/web/src/pages/settings/LogSettings.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { api, type AppSettings } from "../../api/client"; +import { confirmDialog } from "../../utils/dialogs"; const INTERVAL_OPTIONS: { value: number; label: string }[] = [ { value: 1, label: "Every hour" }, @@ -51,7 +52,7 @@ export default function LogSettings() { } async function handlePurgeNow() { - if (!confirm(`Delete diagnostic and audit log entries older than ${retentionDays} days now?`)) return; + if (!(await confirmDialog({ title: "Delete old log entries", message: `Delete diagnostic and audit log entries older than ${retentionDays} days now?`, confirmLabel: "Delete", danger: true }))) return; setPurging(true); setPurgeError(null); setPurgeResult(null); diff --git a/web/src/pages/settings/TagSettings.tsx b/web/src/pages/settings/TagSettings.tsx index b5e4ef4..e38a648 100644 --- a/web/src/pages/settings/TagSettings.tsx +++ b/web/src/pages/settings/TagSettings.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { api, type TagEntry } from "../../api/client"; import { readableError } from "../../utils/errors"; import { normalizeTagInput, refreshTagColors, tagBadge } from "../../utils/tags"; +import { confirmDialog, promptDialog } from "../../utils/dialogs"; const DEFAULT_PICK = "#3b82f6"; @@ -64,12 +65,20 @@ export default function TagSettings() { } async function rename(t: TagEntry) { - const input = window.prompt(`Rename “${t.name}” to:`, t.name); + const input = await promptDialog({ title: "Rename tag", message: `Rename “${t.name}” to:`, defaultValue: t.name, confirmLabel: "Rename", required: true }); if (input === null) return; const to = normalizeTagInput(input); if (!to || to === t.name) return; const target = tags?.find((x) => x.name === to); - if (target && !window.confirm(`“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`)) return; + if ( + target && + !(await confirmDialog({ + title: "Merge tags", + message: `“${to}” already exists. Merge “${t.name}” into it? Every server tagged “${t.name}” will get “${to}” instead.`, + confirmLabel: "Merge", + })) + ) + return; await run( () => api.tags.rename(t.name, to), (res) => `${res.merged ? "Merged" : "Renamed"} “${t.name}” ${res.merged ? "into" : "to"} “${to}” — ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"} updated.`, @@ -78,7 +87,7 @@ export default function TagSettings() { async function remove(t: TagEntry) { const used = t.count > 0 ? ` It's on ${t.count} server${t.count === 1 ? "" : "s"} and will be removed from ${t.count === 1 ? "it" : "them"}.` : ""; - if (!window.confirm(`Delete the tag “${t.name}”?${used}`)) return; + if (!(await confirmDialog({ title: "Delete tag", message: `Delete the tag “${t.name}”?${used}`, confirmLabel: "Delete", danger: true }))) return; await run( () => api.tags.remove(t.name), (res) => `Deleted “${t.name}”${res.updatedServers > 0 ? ` and removed it from ${res.updatedServers} server${res.updatedServers === 1 ? "" : "s"}` : ""}.`, diff --git a/web/src/utils/dialogs.ts b/web/src/utils/dialogs.ts new file mode 100644 index 0000000..c04b7c7 --- /dev/null +++ b/web/src/utils/dialogs.ts @@ -0,0 +1,69 @@ +// In-app replacements for window.confirm() and window.prompt(). Call sites just await them, the same way they used to +// call the browser's — a single (mounted once, in App) draws whichever is asked for. Several asked for +// at once are shown one after another. + +export interface ConfirmOptions { + title?: string; + message: string; + /** What the main button says — name the action ("Delete", "Stop") rather than leaving it as "OK". */ + confirmLabel?: string; + cancelLabel?: string; + /** Destructive or hard to undo: red button, and the safe choice gets the keyboard focus. */ + danger?: boolean; +} + +export interface PromptOptions { + title?: string; + message: string; + /** Label for the text box itself, when the message above it isn't enough. */ + label?: string; + defaultValue?: string; + placeholder?: string; + confirmLabel?: string; + cancelLabel?: string; + /** Disable the main button until something has been typed. */ + required?: boolean; +} + +export type DialogRequest = + | ({ kind: "confirm"; id: number; resolve: (accepted: boolean) => void } & ConfirmOptions) + | ({ kind: "prompt"; id: number; resolve: (value: string | null) => void } & PromptOptions); + +let nextId = 1; +let queue: DialogRequest[] = []; +let listener: ((current: DialogRequest | null) => void) | null = null; + +function emit() { + listener?.(queue[0] ?? null); +} + +/** For the host: called with whatever should be on screen now (and straight away with what already is). */ +export function subscribe(next: (current: DialogRequest | null) => void): () => void { + listener = next; + emit(); + return () => { + if (listener === next) listener = null; + }; +} + +/** Resolves the dialog on screen. A cancelled confirm is false and a cancelled prompt is null — as with the browser's own. */ +export function settle(request: DialogRequest, accepted: boolean, value = ""): void { + queue = queue.filter((r) => r !== request); + if (request.kind === "confirm") request.resolve(accepted); + else request.resolve(accepted ? value : null); + emit(); +} + +export function confirmDialog(options: ConfirmOptions): Promise { + return new Promise((resolve) => { + queue.push({ ...options, kind: "confirm", id: nextId++, resolve }); + emit(); + }); +} + +export function promptDialog(options: PromptOptions): Promise { + return new Promise((resolve) => { + queue.push({ ...options, kind: "prompt", id: nextId++, resolve }); + emit(); + }); +}