diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts
index ecf634f16..475939ec0 100644
--- a/frontend/src/__tests__/apikeys.test.ts
+++ b/frontend/src/__tests__/apikeys.test.ts
@@ -874,7 +874,7 @@ describe('API Keys Module', () => {
jest.useRealTimers();
});
- test('copy button shows alert on clipboard error', async () => {
+ test('copy failure shows an inline error and selects the key, not a native alert (#705)', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const writeTextMock = jest.fn().mockRejectedValue(new Error('Clipboard error'));
Object.defineProperty(navigator, 'clipboard', {
@@ -885,12 +885,24 @@ describe('API Keys Module', () => {
showKeyCreatedModal('test-api-key');
- const copyBtn = document.getElementById('copy-apikey-btn');
- copyBtn?.click();
+ const copyError = document.getElementById('apikey-copy-error') as HTMLElement;
+ expect(copyError.getAttribute('role')).toBe('alert');
+ expect(copyError.classList.contains('hidden')).toBe(true);
+
+ document.getElementById('copy-apikey-btn')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
- expect(window.alert).toHaveBeenCalledWith('Failed to copy to clipboard. Please copy manually.');
+ expect(window.alert).not.toHaveBeenCalled();
+ expect(copyError.classList.contains('hidden')).toBe(false);
+ expect(copyError.textContent).toContain('The key is selected');
+ expect(window.getSelection()?.toString()).toBe('test-api-key');
+
+ // A later successful copy clears the error again.
+ writeTextMock.mockResolvedValueOnce(undefined);
+ document.getElementById('copy-apikey-btn')?.click();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ expect(copyError.classList.contains('hidden')).toBe(true);
consoleError.mockRestore();
});
diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts
index 755426520..96eb58552 100644
--- a/frontend/src/__tests__/users.test.ts
+++ b/frontend/src/__tests__/users.test.ts
@@ -2103,33 +2103,18 @@ describe('users/handlers', () => {
expect(api.updateUser).not.toHaveBeenCalled();
});
- it('should set up bulk group handler', async () => {
- const mockUsers = [{ id: '1', email: 'test@test.com', groups: [], mfa_enabled: false }];
- userState.setAllUsers(mockUsers as any);
- userState.addSelectedUserId('1');
- (global.prompt as jest.Mock).mockReturnValue('admins');
-
- userHandlers.setupUserHandlers();
-
- const bulkGroupBtn = document.getElementById('bulk-group-btn');
- bulkGroupBtn?.click();
-
- await new Promise(resolve => setTimeout(resolve, 0));
-
- expect(api.updateUser).toHaveBeenCalled();
- });
-
- it('should handle cancelled group prompt', async () => {
+ it('never falls back to a native prompt() for bulk add to group (#705)', async () => {
userState.addSelectedUserId('1');
- (global.prompt as jest.Mock).mockReturnValue(null);
userHandlers.setupUserHandlers();
- const bulkGroupBtn = document.getElementById('bulk-group-btn');
- bulkGroupBtn?.click();
+ // A stray legacy #bulk-group-btn must stay inert: the dropdown is the
+ // only bulk add-to-group path.
+ document.getElementById('bulk-group-btn')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
+ expect(global.prompt).not.toHaveBeenCalled();
expect(api.updateUser).not.toHaveBeenCalled();
});
});
diff --git a/frontend/src/apikeys.ts b/frontend/src/apikeys.ts
index 31cd71265..ce3d3789f 100644
--- a/frontend/src/apikeys.ts
+++ b/frontend/src/apikeys.ts
@@ -431,6 +431,7 @@ export function showKeyCreatedModal(apiKey: string): void {
${escapeHtml(apiKey)}
+
Use this key in the X-API-Key header when making API requests:
@@ -450,6 +451,7 @@ export function showKeyCreatedModal(apiKey: string): void {
if (copyBtn) {
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(apiKey).then(() => {
+ document.getElementById('apikey-copy-error')?.classList.add('hidden');
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {
@@ -458,7 +460,15 @@ export function showKeyCreatedModal(apiKey: string): void {
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
- alert('Failed to copy to clipboard. Please copy manually.');
+ // Inline message instead of a native alert() (#705): select the key so
+ // Ctrl+C / Cmd+C copies it without leaving the modal.
+ const valueEl = document.getElementById('apikey-value');
+ if (valueEl) window.getSelection()?.selectAllChildren(valueEl);
+ const copyError = document.getElementById('apikey-copy-error');
+ if (copyError) {
+ copyError.textContent = 'Could not copy automatically. The key is selected: press Ctrl+C (Cmd+C on Mac) to copy it.';
+ copyError.classList.remove('hidden');
+ }
});
});
}
diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css
index ac40fa77d..05b3bb572 100644
--- a/frontend/src/styles/modals.css
+++ b/frontend/src/styles/modals.css
@@ -76,6 +76,24 @@
margin-bottom: 1rem;
}
+/* Example command in a modal (e.g. the curl line in the "API Key Created"
+ dialog). Wraps instead of running past the modal's right edge. */
+.modal-content .code-example {
+ max-width: 100%;
+ box-sizing: border-box;
+ margin-top: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--cudly-bg);
+ border: 1px solid var(--cudly-border-strong);
+ border-radius: 4px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.85rem;
+ line-height: 1.4;
+ color: var(--cudly-text);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
/* Modal overlay */
.modal-overlay {
position: fixed;
diff --git a/frontend/src/users/handlers.ts b/frontend/src/users/handlers.ts
index 635af3d31..cc4a8166d 100644
--- a/frontend/src/users/handlers.ts
+++ b/frontend/src/users/handlers.ts
@@ -7,7 +7,6 @@
* will not render it.
*/
-import { availableGroups } from './state';
import { handleUserSearch, handleFilterChange, clearFilters, updateGroupFilterDropdown } from './filters';
import { openCreateUserModal, closeUserModal, saveUser } from './userModals';
import { bulkDeleteUsers, bulkAddToGroup } from './userActions';
@@ -73,18 +72,9 @@ export function setupUserHandlers(): void {
// Any existing UI element for it is intentionally left unwired so
// it becomes a no-op rather than crashing.
- const bulkGroupBtn = document.getElementById('bulk-group-btn');
- if (bulkGroupBtn) {
- bulkGroupBtn.addEventListener('click', () => {
- // Show group selection dialog
- const groupId = prompt(`Enter group ID (available: ${availableGroups.map(g => g.name).join(', ')}):`);
- if (groupId) {
- void bulkAddToGroup(groupId);
- }
- });
- }
-
- // Bulk group select dropdown (replaces the prompt on the production toolbar)
+ // Bulk add to group goes through the #bulk-group-select dropdown. The old
+ // #bulk-group-btn handler asked for a group ID via native prompt(); that
+ // button is not in index.html, so the dead handler was removed (#705).
const bulkGroupSelect = document.getElementById('bulk-group-select') as HTMLSelectElement | null;
if (bulkGroupSelect) {
bulkGroupSelect.addEventListener('change', () => {
diff --git a/frontend/tests-e2e/apikey-copy-failure.spec.ts b/frontend/tests-e2e/apikey-copy-failure.spec.ts
new file mode 100644
index 000000000..7ea32b5ac
--- /dev/null
+++ b/frontend/tests-e2e/apikey-copy-failure.spec.ts
@@ -0,0 +1,97 @@
+import { test, expect, type Page } from '@playwright/test';
+import { mockApi, seedAuth } from './fixtures/recs';
+
+const CREATED_KEY = 'cudly_e2e_key_123';
+
+async function mockApiKeysPage(page: Page): Promise
{
+ await seedAuth(page);
+ await mockApi(page);
+ await page.route('**/api/users*', route => route.fulfill({ json: { users: [] } }));
+ await page.route('**/api/groups*', route => route.fulfill({ json: { groups: [] } }));
+ await page.route('**/api/api-keys/usage-stats', route => route.fulfill({ json: {
+ total_active: 0, total_requests_window: 0, total_requests_lifetime: 0, lifetime_partial: false, top_keys: [],
+ } }));
+ await page.route('**/api/api-keys', route => {
+ if (route.request().method() === 'POST') {
+ return route.fulfill({ json: { api_key: CREATED_KEY, key_id: 'k1', info: { id: 'k1', name: 'CI key' } } });
+ }
+ return route.fulfill({ json: { api_keys: [] } });
+ });
+ await page.route('**/api/auth/me/permissions', route => route.fulfill({ json: {
+ permissions: [{ action: 'view', resource: 'recommendations' }], is_admin: true,
+ } }));
+}
+
+async function createKey(page: Page): Promise {
+ await page.goto('/admin/users');
+ await page.locator('#create-apikey-btn').click();
+ await page.locator('#apikey-name').fill('CI key');
+ await page.locator('#apikey-password').fill('correct horse battery');
+ await page.locator('#apikey-permissions-list input[type="checkbox"]').first().check();
+ await page.locator('#create-apikey-submit-btn').click();
+ await expect(page.locator('#apikey-created-modal')).toBeVisible();
+}
+
+// Issue #705: a failed clipboard write on the one-time API key modal used a
+// native alert(). It now shows an inline role="alert" message and selects the
+// key so the user can copy it by hand without leaving the modal.
+test('API key copy failure is reported inline, not with a native alert', async ({ page }) => {
+ await mockApiKeysPage(page);
+ await page.addInitScript(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: () => Promise.reject(new Error('Clipboard blocked')) },
+ });
+ });
+ const nativeDialogs: string[] = [];
+ page.on('dialog', async dialog => {
+ nativeDialogs.push(dialog.message());
+ await dialog.dismiss();
+ });
+
+ await createKey(page);
+
+ await page.locator('#copy-apikey-btn').click();
+
+ const copyError = page.locator('#apikey-copy-error');
+ await expect(copyError).toBeVisible();
+ await expect(copyError).toHaveAttribute('role', 'alert');
+ await expect(copyError).toContainText('The key is selected');
+ expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(CREATED_KEY);
+ expect(nativeDialogs).toEqual([]);
+});
+
+// The curl example in the same modal used to run past the modal's right edge
+// and get clipped (#705). It now wraps, so it must stay inside the modal.
+for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) {
+ test(`API key curl example stays inside the modal at ${viewport.width}x${viewport.height}`, async ({ page }) => {
+ await page.setViewportSize(viewport);
+ await mockApiKeysPage(page);
+ await createKey(page);
+
+ const modalBox = await page.locator('#apikey-created-modal .modal-content').boundingBox();
+ const codeBox = await page.locator('#apikey-created-modal .code-example').boundingBox();
+ expect(modalBox).not.toBeNull();
+ expect(codeBox).not.toBeNull();
+ expect(codeBox!.x).toBeGreaterThanOrEqual(modalBox!.x);
+ expect(codeBox!.x + codeBox!.width).toBeLessThanOrEqual(modalBox!.x + modalBox!.width);
+ // The box itself always fits; what overflowed was its text. Check the
+ // rendered text's right edge and that nothing scrolls sideways.
+ const layout = await page.evaluate(() => {
+ const modal = document.querySelector('#apikey-created-modal .modal-content')!;
+ const code = document.querySelector('#apikey-created-modal .code-example')!;
+ const range = document.createRange();
+ range.selectNodeContents(code);
+ return {
+ textRight: range.getBoundingClientRect().right,
+ modalRight: modal.getBoundingClientRect().right,
+ codeOverflow: code.scrollWidth - code.clientWidth,
+ modalOverflow: modal.scrollWidth - modal.clientWidth,
+ };
+ });
+ expect(layout.textRight).toBeLessThanOrEqual(layout.modalRight);
+ expect(layout.codeOverflow).toBeLessThanOrEqual(0);
+ expect(layout.modalOverflow).toBeLessThanOrEqual(0);
+ await expect(page.locator('#apikey-created-modal .code-example')).toContainText('https://your-api-endpoint.com/api/...');
+ });
+}