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:
2026-03-29 13:11:21 +03:00
parent 9f284932a1
commit f6f758c4e7
5 changed files with 173 additions and 8 deletions
+18 -1
View File
@@ -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<Stage[]>([]);
let selectedStageId = $state('');
@@ -27,6 +28,8 @@
let editValue = $state('');
let editEncrypted = $state(false);
let envDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id);
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')}>
<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(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} />
</button>
</div>
@@ -331,3 +334,17 @@
{/if}
{/if}
</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 Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let volumes = $state<Volume[]>([]);
let loading = $state(true);
@@ -22,6 +23,8 @@
let editTarget = $state('');
let editMode = $state<'shared' | 'isolated'>('shared');
let volumeDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id);
async function loadVolumes() {
@@ -171,7 +174,7 @@
<td class="whitespace-nowrap px-4 py-2.5 text-right">
<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-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>
</td>
</tr>
@@ -213,3 +216,17 @@
{/if}
{/if}
</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; }}
/>
+19 -3
View File
@@ -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<User | null>(null);
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
function authHeaders(): Record<string, string> { 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'); }
}
</script>
@@ -165,7 +167,7 @@
</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">
<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} />
</button>
</td>
@@ -198,3 +200,17 @@
</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 { t } from '$lib/i18n';
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let registries = $state<Registry[]>([]);
let loading = $state(true);
@@ -22,6 +23,7 @@
let testingId = $state<string | null>(null);
let healthStatus = $state<Record<string, 'checking' | 'healthy' | 'unhealthy'>>({});
let registryDeleteTarget = $state<Registry | null>(null);
let errors = $state<Record<string, string>>({});
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')}
</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>
{/each}
</div>
{/if}
</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; }}
/>