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 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5.5 committed 2026-10-02 23:07:43 +02:00
1 parent 88d9c8e097
commit ad1fb5338f
24 files changed
+314 -39

No files matched your search

+2
View File
@@ -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<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
@@ -165,4 +166,5 @@ export default function App() {
</Routes>
</AppShell>
);
<DialogHost />
}
+146
View File
@@ -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<DialogRequest | null>(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(<DialogView key={current.id} request={current} />, document.body);
}
function DialogView({ request }: { request: DialogRequest }) {
const titleId = useId();
const inputId = useId();
const modalRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(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<HTMLElement>(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 (
<>
<div
ref={modalRef}
className="modal modal-blur fade show"
style={{ display: "block" }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={onKeyDown}
// mousedown rather than click, so selecting text in the box and letting go outside it doesn't close the dialog
onMouseDown={(e) => {
if (e.target === e.currentTarget) cancel();
}}
>
<div className="modal-dialog modal-dialog-centered" role="document">
<form className="modal-content" onSubmit={onSubmit}>
{danger && <div className="modal-status bg-danger" />}
<div className="modal-header">
<h5 className="modal-title" id={titleId}>
{title}
</h5>
<button type="button" className="btn-close" aria-label="Close" onClick={cancel} />
</div>
<div className="modal-body">
<div style={{ whiteSpace: "pre-line" }}>{request.message}</div>
{request.kind === "prompt" && (
<div className="mt-3">
{request.label && (
<label className="form-label" htmlFor={inputId}>
{request.label}
</label>
)}
<input
id={inputId}
ref={inputRef}
className="form-control"
value={value}
placeholder={request.placeholder}
onChange={(e) => setValue(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn me-auto" ref={cancelRef} onClick={cancel}>
{request.cancelLabel ?? "Cancel"}
</button>
<button type="submit" className={`btn ${danger ? "btn-danger" : "btn-primary"}`} ref={confirmRef} disabled={blocked}>
{request.confirmLabel ?? (isPrompt ? "OK" : "Confirm")}
</button>
</div>
</form>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
+3 -1
View File
@@ -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));
+2 -1
View File
@@ -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);
+15 -7
View File
@@ -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()]);
}
+2 -1
View File
@@ -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<string, string> = {
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();
+3 -2
View File
@@ -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<DnsProviderType, string> = {
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);
+2 -1
View File
@@ -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 <span className="text-secondary">—</span>;
@@ -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 {
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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<IntegrationType, string> = {
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();
+11 -6
View File
@@ -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 {
+2 -1
View File
@@ -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);
+11 -1
View File
@@ -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);
+3 -2
View File
@@ -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<SecretRecord["type"], string> = {
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 {
+2 -1
View File
@@ -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 {
+12 -3
View File
@@ -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);
+2 -1
View File
@@ -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<string, string>, 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();
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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 {
+2 -1
View File
@@ -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);
+2 -1
View File
@@ -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<string | null>(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);
+2 -1
View File
@@ -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);
+12 -3
View File
@@ -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"}` : ""}.`,
+69
View File
@@ -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 <DialogHost /> (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<boolean> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "confirm", id: nextId++, resolve });
emit();
});
}
export function promptDialog(options: PromptOptions): Promise<string | null> {
return new Promise((resolve) => {
queue.push({ ...options, kind: "prompt", id: nextId++, resolve });
emit();
});
}