Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/e2e/src/generated/test-ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1283,7 +1283,7 @@ export const TestIds = {
credAddBtn: 'cred-add-btn',
/** button · Clear filters */
credClearFilters: 'cred-clear-filters',
/** button */
/** button · Close saved credentials */
credCloseBtn: 'cred-close-btn',
/** button · Delete */
credDelete: (id: string | number) => `cred-delete-${id}`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';

// The editor store loads the theme store, which reads the colour scheme.
window.matchMedia ??= ((media: string) => ({
matches: false,
media,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;

// The credential form is its own layer; a stand-in is enough to open it.
vi.mock('./ConnectionModal', () => ({
ConnectionModal: ({ open }: { open: boolean }) => (open ? <div data-testid="connection-form" /> : null),
}));

import { CredentialManager } from './CredentialManager';

describe('CredentialManager', () => {
it('is a modal dialog named Saved Credentials, with a named close button', () => {
render(<CredentialManager open onClose={() => undefined} />);
const dialog = screen.getByRole('dialog', { name: 'Saved Credentials' });
expect(dialog.getAttribute('aria-modal')).toBe('true');
expect(screen.getByRole('button', { name: 'Close saved credentials' })).toBeTruthy();
});

it('closes on Escape', () => {
const onClose = vi.fn();
render(<CredentialManager open onClose={onClose} />);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(1);
});

it('leaves Escape to the credential form while it is open on top', () => {
const onClose = vi.fn();
render(<CredentialManager open onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: /Add Credential/ }));
expect(screen.getByTestId('connection-form')).toBeTruthy();
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { CloudProviderCredentialsSection } from '@/features/connections/componen
import { PROVIDER_SETTINGS, type ConnectionOptions, type Dialect } from '@/shared/lib/provider-settings';
import { dialectLabel } from '@/shared/lib/dialectLabel';
import { sessionPasswordMap, setSessionPassword } from '@/shared/lib/sessionPasswords';
import { useEscapeKey } from '@/shared/lib/useEscapeKey';
import type { SavedConnectionSummary } from '@/shared/api/authApi';

interface Props {
Expand Down Expand Up @@ -157,6 +158,9 @@ export const CredentialManager: React.FC<Props> = ({ open, onClose }) => {
return groups;
}, [visible]);

// Escape closes the manager, but not while the credential form is open on top.
useEscapeKey(onClose, open && !adding && !editing);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Form layer leaves Escape unhandled

Medium Severity

useEscapeKey is disabled while adding or editing so Escape can close the credential form on top, but ConnectionModal has no Escape handler. After Add or Edit Credential, Escape dismisses neither layer. Users who just used Escape to close Saved Credentials now get no response.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bf24fd4. Configure here.


if (!open) return null;

return createPortal(
Expand All @@ -165,6 +169,9 @@ export const CredentialManager: React.FC<Props> = ({ open, onClose }) => {
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="cred-manager-title"
className="w-full max-w-[620px] bg-slate-900 border border-slate-800 rounded-xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
Expand All @@ -174,7 +181,7 @@ export const CredentialManager: React.FC<Props> = ({ open, onClose }) => {
<KeyRound className="w-4.5 h-4.5 text-cyan-400" />
</div>
<div>
<h2 className="text-slate-100 font-bold text-base">Saved Credentials</h2>
<h2 id="cred-manager-title" className="text-slate-100 font-bold text-base">Saved Credentials</h2>
<p className="text-xs text-slate-400 mt-0.5">
{tab === 'database'
? 'Encrypted DB connections · reusable from the connection dropdowns'
Expand All @@ -192,6 +199,8 @@ export const CredentialManager: React.FC<Props> = ({ open, onClose }) => {
)}
<button
data-testid="cred-close-btn"
type="button"
aria-label="Close saved credentials"
onClick={onClose}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-400 hover:text-slate-200 transition"
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { WriteConfirmDialog } from './WriteConfirmDialog';

describe('WriteConfirmDialog', () => {
it('is announced as a modal dialog named by its title', () => {
render(
<WriteConfirmDialog
writeStatements={['UPDATE products SET name = name WHERE id = 1']}
credentialCount={1}
onCancel={() => undefined}
onConfirm={() => undefined}
/>
);
const dialog = screen.getByRole('dialog', { name: 'Safe mode: run UPDATE statements?' });
expect(dialog.getAttribute('aria-modal')).toBe('true');
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react';
import React, { useId, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { AlertCircle, Play, Info, ShieldAlert } from 'lucide-react';
import type { ReadonlyWriteTarget } from '@/features/sql-editor/state/useSqlEditorStore';
Expand Down Expand Up @@ -68,6 +68,7 @@ export const WriteConfirmDialog: React.FC<Props> = ({
return [...set];
}, [mutating]);

const titleId = useId();
const [ackedMissingWhere, setAckedMissingWhere] = useState(false);
const needsWhereAck = requireMissingWhereAck && missingWhere.length > 0;
const canConfirm = !needsWhereAck || ackedMissingWhere;
Expand All @@ -88,12 +89,15 @@ export const WriteConfirmDialog: React.FC<Props> = ({
onClick={onCancel}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full max-w-[520px] bg-slate-900 border border-slate-800 rounded-xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-4 border-b border-slate-800 bg-slate-950/40 flex items-center gap-2.5">
<ShieldAlert className="w-5 h-5 text-rose-400 shrink-0" strokeWidth={SQL_ICON_STROKE} />
<h2 className="text-slate-100 font-bold text-base">{title}</h2>
<h2 id={titleId} className="text-slate-100 font-bold text-base">{title}</h2>
</div>
<div className="p-6 space-y-3">
{mutating.length > 0 && (
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/frontend/shared/lib/useEscapeKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { useEffect, useRef } from 'react';

/**
* Call `onEscape` when Escape is pressed, while `enabled`. A key another
* handler already claimed (`preventDefault`, for example an open menu inside
* the dialog) is left alone, and so is everything while `enabled` is false:
* a dialog with another layer open on top passes false, so Escape closes only
* the top one.
*
* Registered once per enabled state; callers may pass a fresh function on
* every render.
*/
export function useEscapeKey(onEscape: () => void, enabled = true): void {
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !e.defaultPrevented) onEscapeRef.current();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [enabled]);
}
2 changes: 1 addition & 1 deletion docs/testing/TEST_IDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -671,7 +671,7 @@ Every control has a test ID: **1015** buttons, text boxes, selects and textareas
- **CredentialManager** · `features/connections/components/CredentialManager.tsx`
- `cred-add-btn` · button · Add Credential
- `cred-clear-filters` · button · Clear filters
- `cred-close-btn` · button
- `cred-close-btn` · button · Close saved credentials
- `cred-delete-{id}` · button · Delete
- `cred-dialect-filter` · select · Filter by provider
- `cred-edit-{id}` · button · Edit
Expand Down
Loading