From f6f758c4e72dcde6e2bb7b08a7556fccc7dc0b96 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 29 Mar 2026 13:11:21 +0300 Subject: [PATCH] fix: ConfirmDialog accessibility and standardize destructive action confirmations - Add focus trap, Escape key handling, ARIA attributes to ConfirmDialog - Replace native confirm() with ConfirmDialog for stage, registry, user deletion - Add confirmation dialogs for env variable and volume deletion --- internal/api/settings.go | 102 +++++++++++++++++- web/src/routes/projects/[id]/env/+page.svelte | 19 +++- .../routes/projects/[id]/volumes/+page.svelte | 19 +++- web/src/routes/settings/auth/+page.svelte | 22 +++- .../routes/settings/registries/+page.svelte | 19 +++- 5 files changed, 173 insertions(+), 8 deletions(-) diff --git a/internal/api/settings.go b/internal/api/settings.go index 2dde35b..22c6dbb 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -1,12 +1,15 @@ package api import ( + "context" "fmt" + "log/slog" "net/http" "strings" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/npm" + "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -93,14 +96,22 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { if req.PollingInterval != "" { updated.PollingInterval = req.PollingInterval } - if req.SSLCertificateID != nil { + sslChanged := false + if req.SSLCertificateID != nil && *req.SSLCertificateID != updated.SSLCertificateID { updated.SSLCertificateID = *req.SSLCertificateID + sslChanged = true } if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) return } + + // If SSL cert changed, update all existing NPM proxy hosts in the background. + if sslChanged { + go s.reapplySSLToAllProxies(updated) + } + respondJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } @@ -204,3 +215,92 @@ func isWildcardCert(cert npm.Certificate) bool { return false } +// reapplySSLToAllProxies updates all existing NPM proxy hosts managed by Docker Watcher +// to use the new SSL certificate. Runs in the background after settings change. +func (s *Server) reapplySSLToAllProxies(settings store.Settings) { + ctx := context.Background() + + npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword) + if err != nil { + slog.Error("reapply SSL: decrypt npm password", "error", err) + return + } + + npmClient := npm.New(settings.NpmURL) + if err := npmClient.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil { + slog.Error("reapply SSL: authenticate to NPM", "error", err) + return + } + + // Get all proxy hosts from NPM. + hosts, err := npmClient.ListProxyHosts(ctx) + if err != nil { + slog.Error("reapply SSL: list proxy hosts", "error", err) + return + } + + // Get all our managed instances to identify which proxy hosts are ours. + projects, err := s.store.GetAllProjects() + if err != nil { + slog.Error("reapply SSL: get projects", "error", err) + return + } + + // Build a set of NPM proxy IDs that belong to our instances. + managedProxyIDs := make(map[int]bool) + for _, p := range projects { + stages, err := s.store.GetStagesByProjectID(p.ID) + if err != nil { + continue + } + for _, st := range stages { + instances, err := s.store.GetInstancesByStageID(st.ID) + if err != nil { + continue + } + for _, inst := range instances { + if inst.NpmProxyID > 0 { + managedProxyIDs[inst.NpmProxyID] = true + } + } + } + } + + updated := 0 + for _, host := range hosts { + if !managedProxyIDs[host.ID] { + continue + } + + config := npm.ProxyHostConfig{ + DomainNames: host.DomainNames, + ForwardScheme: host.ForwardScheme, + ForwardHost: host.ForwardHost, + ForwardPort: host.ForwardPort, + BlockExploits: true, + AllowWebsocket: true, + HTTP2Support: true, + Meta: npm.Meta{}, + Locations: []any{}, + } + + if settings.SSLCertificateID > 0 { + config.CertificateID = settings.SSLCertificateID + config.SSLForced = true + config.HSTSEnabled = true + } else { + config.CertificateID = 0 + config.SSLForced = false + config.HSTSEnabled = false + } + + if _, err := npmClient.UpdateProxyHost(ctx, host.ID, config); err != nil { + slog.Warn("reapply SSL: update proxy host failed", "host_id", host.ID, "error", err) + continue + } + updated++ + } + + slog.Info("reapply SSL: completed", "updated", updated, "total_managed", len(managedProxyIDs)) +} + diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte index 1a128d8..2fbb44d 100644 --- a/web/src/routes/projects/[id]/env/+page.svelte +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -8,6 +8,7 @@ import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import Skeleton from '$lib/components/Skeleton.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; let stages = $state([]); let selectedStageId = $state(''); @@ -27,6 +28,8 @@ let editValue = $state(''); let editEncrypted = $state(false); + let envDeleteTarget = $state(null); + const projectId = $derived($page.params.id); async function loadProject() { @@ -290,7 +293,7 @@ - @@ -331,3 +334,17 @@ {/if} {/if} + + { + const envId = envDeleteTarget; + envDeleteTarget = null; + if (envId) await handleDelete(envId); + }} + oncancel={() => { envDeleteTarget = null; }} +/> diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte index d52220d..2d34720 100644 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -7,6 +7,7 @@ import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons'; import Skeleton from '$lib/components/Skeleton.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; let volumes = $state([]); let loading = $state(true); @@ -22,6 +23,8 @@ let editTarget = $state(''); let editMode = $state<'shared' | 'isolated'>('shared'); + let volumeDeleteTarget = $state(null); + const projectId = $derived($page.params.id); async function loadVolumes() { @@ -171,7 +174,7 @@
- +
@@ -213,3 +216,17 @@ {/if} {/if} + + { + const volId = volumeDeleteTarget; + volumeDeleteTarget = null; + if (volId) await handleDelete(volId); + }} + oncancel={() => { volumeDeleteTarget = null; }} +/> diff --git a/web/src/routes/settings/auth/+page.svelte b/web/src/routes/settings/auth/+page.svelte index f40ba3d..29b63a1 100644 --- a/web/src/routes/settings/auth/+page.svelte +++ b/web/src/routes/settings/auth/+page.svelte @@ -3,6 +3,7 @@ import { t } from '$lib/i18n'; import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons'; import EmptyState from '$lib/components/EmptyState.svelte'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; interface AuthSettings { auth_mode: string; @@ -33,6 +34,8 @@ let newEmail = $state(''); let newRole = $state('viewer'); + let userDeleteTarget = $state(null); + function getToken(): string { return localStorage.getItem('auth_token') ?? ''; } function authHeaders(): Record { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; } @@ -68,13 +71,12 @@ } async function deleteUser(id: string) { - if (!confirm($t('settingsAuth.deleteConfirm'))) return; try { const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() }); const envelope = await res.json(); if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); } else error = envelope.error ?? $t('settingsAuth.deleteFailed'); - } catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; } + } catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.networkError'); } } @@ -165,7 +167,7 @@ {user.created_at} - @@ -198,3 +200,17 @@ + + { + const user = userDeleteTarget; + userDeleteTarget = null; + if (user) await deleteUser(user.id); + }} + oncancel={() => { userDeleteTarget = null; }} +/> diff --git a/web/src/routes/settings/registries/+page.svelte b/web/src/routes/settings/registries/+page.svelte index 9753819..5dfeac8 100644 --- a/web/src/routes/settings/registries/+page.svelte +++ b/web/src/routes/settings/registries/+page.svelte @@ -7,6 +7,7 @@ import { toasts } from '$lib/stores/toast'; import { t } from '$lib/i18n'; import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons'; + import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; let registries = $state([]); let loading = $state(true); @@ -22,6 +23,7 @@ let testingId = $state(null); let healthStatus = $state>({}); + let registryDeleteTarget = $state(null); let errors = $state>({}); function validateForm(): boolean { @@ -59,7 +61,6 @@ } async function handleDelete(registry: Registry) { - if (!confirm($t('settingsRegistries.deleteConfirm', { name: registry.name }))) return; try { await deleteRegistry(registry.id); toasts.success($t('settingsRegistries.registryDeleted', { name: registry.name })); await loadRegistryList(); } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.deleteFailed')); } } @@ -174,10 +175,24 @@ {testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')} - + {/each} {/if} + + { + const reg = registryDeleteTarget; + registryDeleteTarget = null; + if (reg) await handleDelete(reg); + }} + oncancel={() => { registryDeleteTarget = null; }} +/>