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
20 changes: 16 additions & 4 deletions frontend/src/__tests__/apikeys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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();
});

Expand Down
25 changes: 5 additions & 20 deletions frontend/src/__tests__/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/apikeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ export function showKeyCreatedModal(apiKey: string): void {
<code id="apikey-value" class="apikey-value">${escapeHtml(apiKey)}</code>
<button type="button" id="copy-apikey-btn" class="btn-small primary">Copy</button>
</div>
<p id="apikey-copy-error" class="field-error hidden" role="alert"></p>
</div>
<div class="modal-info">
<p>Use this key in the <code>X-API-Key</code> header when making API requests:</p>
Expand All @@ -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(() => {
Expand All @@ -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');
}
});
});
}
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/styles/modals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 3 additions & 13 deletions frontend/src/users/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
97 changes: 97 additions & 0 deletions frontend/tests-e2e/apikey-copy-failure.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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 <pre> 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/...');
});
}
Loading