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(); + }); +}