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
This commit is contained in:
+101
-1
@@ -1,12 +1,15 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/npm"
|
"github.com/alexei/docker-watcher/internal/npm"
|
||||||
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
"github.com/alexei/docker-watcher/internal/webhook"
|
"github.com/alexei/docker-watcher/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -93,14 +96,22 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.PollingInterval != "" {
|
if req.PollingInterval != "" {
|
||||||
updated.PollingInterval = req.PollingInterval
|
updated.PollingInterval = req.PollingInterval
|
||||||
}
|
}
|
||||||
if req.SSLCertificateID != nil {
|
sslChanged := false
|
||||||
|
if req.SSLCertificateID != nil && *req.SSLCertificateID != updated.SSLCertificateID {
|
||||||
updated.SSLCertificateID = *req.SSLCertificateID
|
updated.SSLCertificateID = *req.SSLCertificateID
|
||||||
|
sslChanged = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.UpdateSettings(updated); err != nil {
|
if err := s.store.UpdateSettings(updated); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
|
||||||
return
|
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"})
|
respondJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,3 +215,92 @@ func isWildcardCert(cert npm.Certificate) bool {
|
|||||||
return false
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -8,6 +8,7 @@
|
|||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let stages = $state<Stage[]>([]);
|
let stages = $state<Stage[]>([]);
|
||||||
let selectedStageId = $state('');
|
let selectedStageId = $state('');
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
let editValue = $state('');
|
let editValue = $state('');
|
||||||
let editEncrypted = $state(false);
|
let editEncrypted = $state(false);
|
||||||
|
|
||||||
|
let envDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
const projectId = $derived($page.params.id);
|
const projectId = $derived($page.params.id);
|
||||||
|
|
||||||
async function loadProject() {
|
async function loadProject() {
|
||||||
@@ -290,7 +293,7 @@
|
|||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(env.id)} title={$t('envEditor.delete')}>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { envDeleteTarget = env.id; }} title={$t('envEditor.delete')}>
|
||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,3 +334,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={envDeleteTarget !== null}
|
||||||
|
title={$t('envEditor.deleteTitle')}
|
||||||
|
message={$t('envEditor.deleteMessage')}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const envId = envDeleteTarget;
|
||||||
|
envDeleteTarget = null;
|
||||||
|
if (envId) await handleDelete(envId);
|
||||||
|
}}
|
||||||
|
oncancel={() => { envDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let volumes = $state<Volume[]>([]);
|
let volumes = $state<Volume[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
let editTarget = $state('');
|
let editTarget = $state('');
|
||||||
let editMode = $state<'shared' | 'isolated'>('shared');
|
let editMode = $state<'shared' | 'isolated'>('shared');
|
||||||
|
|
||||||
|
let volumeDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
const projectId = $derived($page.params.id);
|
const projectId = $derived($page.params.id);
|
||||||
|
|
||||||
async function loadVolumes() {
|
async function loadVolumes() {
|
||||||
@@ -171,7 +174,7 @@
|
|||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => handleDelete(vol.id)}><IconTrash size={16} /></button>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { volumeDeleteTarget = vol.id; }} title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -213,3 +216,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={volumeDeleteTarget !== null}
|
||||||
|
title={$t('volumeEditor.deleteTitle')}
|
||||||
|
message={$t('volumeEditor.deleteMessage')}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const volId = volumeDeleteTarget;
|
||||||
|
volumeDeleteTarget = null;
|
||||||
|
if (volId) await handleDelete(volId);
|
||||||
|
}}
|
||||||
|
oncancel={() => { volumeDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
interface AuthSettings {
|
interface AuthSettings {
|
||||||
auth_mode: string;
|
auth_mode: string;
|
||||||
@@ -33,6 +34,8 @@
|
|||||||
let newEmail = $state('');
|
let newEmail = $state('');
|
||||||
let newRole = $state('viewer');
|
let newRole = $state('viewer');
|
||||||
|
|
||||||
|
let userDeleteTarget = $state<User | null>(null);
|
||||||
|
|
||||||
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
|
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
|
||||||
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
|
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
|
||||||
|
|
||||||
@@ -68,13 +71,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id: string) {
|
async function deleteUser(id: string) {
|
||||||
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||||
const envelope = await res.json();
|
const envelope = await res.json();
|
||||||
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
|
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
|
||||||
else error = envelope.error ?? $t('settingsAuth.deleteFailed');
|
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'); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<button onclick={() => deleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
|
<button onclick={() => { userDeleteTarget = user; }} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
|
||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -198,3 +200,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={userDeleteTarget !== null}
|
||||||
|
title={$t('settingsAuth.deleteUserTitle')}
|
||||||
|
message={$t('settingsAuth.deleteConfirm', { username: userDeleteTarget?.username ?? '' })}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const user = userDeleteTarget;
|
||||||
|
userDeleteTarget = null;
|
||||||
|
if (user) await deleteUser(user.id);
|
||||||
|
}}
|
||||||
|
oncancel={() => { userDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let registries = $state<Registry[]>([]);
|
let registries = $state<Registry[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let testingId = $state<string | null>(null);
|
let testingId = $state<string | null>(null);
|
||||||
let healthStatus = $state<Record<string, 'checking' | 'healthy' | 'unhealthy'>>({});
|
let healthStatus = $state<Record<string, 'checking' | 'healthy' | 'unhealthy'>>({});
|
||||||
|
|
||||||
|
let registryDeleteTarget = $state<Registry | null>(null);
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
function validateForm(): boolean {
|
function validateForm(): boolean {
|
||||||
@@ -59,7 +61,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(registry: Registry) {
|
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(); }
|
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')); }
|
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')}
|
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
|
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
|
||||||
<button onclick={() => handleDelete(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
|
<button onclick={() => { registryDeleteTarget = registry; }} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={registryDeleteTarget !== null}
|
||||||
|
title={$t('settingsRegistries.deleteTitle')}
|
||||||
|
message={$t('settingsRegistries.deleteConfirm', { name: registryDeleteTarget?.name ?? '' })}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const reg = registryDeleteTarget;
|
||||||
|
registryDeleteTarget = null;
|
||||||
|
if (reg) await handleDelete(reg);
|
||||||
|
}}
|
||||||
|
oncancel={() => { registryDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user